diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/UsersRestService.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/UsersRestService.java new file mode 100644 index 000000000000..1425820e8804 --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/UsersRestService.java @@ -0,0 +1,527 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.web.rest.v2; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.Status; +import javax.ws.rs.core.SecurityContext; + +import org.opennms.netmgt.config.UserManager; +import org.opennms.netmgt.config.api.UserConfig.ContactType; +import org.opennms.netmgt.config.users.Contact; +import org.opennms.netmgt.config.users.Password; +import org.opennms.netmgt.config.users.User; +import org.opennms.web.api.Authentication; +import org.opennms.web.rest.v2.api.UsersRestApi; +import org.opennms.web.rest.v2.model.UserDto; +import org.opennms.web.rest.v2.model.UserPasswordRequest; +import org.opennms.web.rest.v2.model.UserRenameRequest; +import org.opennms.web.rest.v2.model.UserWriteRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * Versioned user management on top of {@link UserManager}: users.xml remains + * the system of record and hand-editing keeps working. Unlike the legacy JSPs + * (which only hid the buttons), the admin/rtc delete and rename protections + * are enforced here, server-side. The password hash is never serialized. + * + * Mutations validate the full request up front and then apply it to a + * detached copy of the stored user, so a rejected request can never leave + * partial changes in the manager's shared in-memory state. + */ +@Component("usersRestServiceV2") +public class UsersRestService implements UsersRestApi { + + // Check-then-act sequences synchronize on GroupFactory.class: user + // mutations cascade into GroupManager (deleteUser/renameUser walk every + // group), so the v2 users and groups services must share one monitor. + + private static final Logger LOG = LoggerFactory.getLogger(UsersRestService.class); + + /** System accounts that must not be deleted or renamed. */ + private static final Set PROTECTED_USERS = Set.of("admin", "rtc"); + + /** + * Rejects markup (legacy servlet rule) plus characters that break how the + * id is used downstream: ':' (HTTP basic auth), whitespace (group + * references), and '/', '\', '%', '?', '#' (the id is a URL path segment + * in every per-user endpoint). + */ + private static final Pattern INVALID_USER_ID = Pattern.compile("[&<>\"`':/\\\\%?#\\s]"); + + /** Same markup characters the groups API rejects in comments. */ + private static final Pattern INVALID_COMMENTS = Pattern.compile("[&<>\"`']"); + + /** + * Day tokens + military begin-end times, e.g. MoWeFr800-1700. Overnight + * schedules (begin after end, e.g. MoTu2000-800) are legal — the legacy + * UI wrote them and hand-edited files contain them. + */ + private static final Pattern DUTY_SCHEDULE = Pattern.compile("^((?:Mo|Tu|We|Th|Fr|Sa|Su){1,7})(\\d{1,4})-(\\d{1,4})$"); + + @Autowired + private UserManager m_userManager; + + /** For the on-call-role supervisor referential check on delete. */ + @Autowired + private org.opennms.netmgt.config.GroupManager m_groupManager; + + @Override + public Response listUsers(final SecurityContext securityContext) { + assertAdmin(securityContext); + try { + synchronized (org.opennms.netmgt.config.GroupFactory.class) { + final List users = new ArrayList<>(); + for (final User user : m_userManager.getUsers().values()) { + users.add(toDto(user)); + } + users.sort(Comparator.comparing(UserDto::getUserId, String.CASE_INSENSITIVE_ORDER)); + return Response.ok(users).build(); + } + } catch (final Exception e) { + return serverError("Can't read users: %s", e); + } + } + + @Override + public Response getUser(final SecurityContext securityContext, final String userId) { + assertAdmin(securityContext); + try { + synchronized (org.opennms.netmgt.config.GroupFactory.class) { + final User user = m_userManager.getUser(userId); + if (user == null) { + return Response.status(Status.NOT_FOUND).entity("User " + userId + " was not found.").build(); + } + return Response.ok(toDto(user)).build(); + } + } catch (final Exception e) { + return serverError("Can't read user: %s", e); + } + } + + @Override + public Response listAvailableRoles(final SecurityContext securityContext) { + assertAdmin(securityContext); + final List roles = new ArrayList<>(Authentication.getAvailableRoles()); + roles.sort(String.CASE_INSENSITIVE_ORDER); + return Response.ok(roles).build(); + } + + @Override + public Response createUser(final SecurityContext securityContext, final UserWriteRequest request) { + assertAdmin(securityContext); + if (request == null || isBlank(request.getUserId())) { + return Response.status(Status.BAD_REQUEST).entity("A user-id is required.").build(); + } + final String userId = request.getUserId().trim(); + final String userIdProblem = validateUserId(userId); + if (userIdProblem != null) { + return Response.status(Status.BAD_REQUEST).entity(userIdProblem).build(); + } + if (isBlank(request.getPassword())) { + return Response.status(Status.BAD_REQUEST).entity("A password is required.").build(); + } + try { + validateDtoFields(request, null); + } catch (final IllegalArgumentException e) { + return Response.status(Status.BAD_REQUEST).entity(e.getMessage()).build(); + } + try { + synchronized (org.opennms.netmgt.config.GroupFactory.class) { + if (m_userManager.hasUser(userId)) { + return Response.status(Status.BAD_REQUEST).entity("User " + userId + " already exists.").build(); + } + final User user = new User(); + user.setUserId(userId); + user.setPassword(m_userManager.encryptedPassword(request.getPassword(), true), Boolean.TRUE); + applyDto(user, request); + try { + m_userManager.saveUser(userId, user); + } catch (final Exception e) { + rollbackPhantomUser(userId); + throw e; + } + } + LOG.info("User {} created by {}", userId, principal(securityContext)); + return Response.status(Status.CREATED).build(); + } catch (final Exception e) { + return serverError("Can't create user: %s", e); + } + } + + @Override + public Response updateUser(final SecurityContext securityContext, final String userId, final UserDto dto) { + assertAdmin(securityContext); + if (dto == null) { + return Response.status(Status.BAD_REQUEST).entity("A user body is required.").build(); + } + if (dto.getUserId() != null && !userId.equals(dto.getUserId())) { + return Response.status(Status.BAD_REQUEST) + .entity("The user-id in the body does not match the request path; use the rename endpoint to change ids.").build(); + } + // stripping ROLE_ADMIN from the admin account would lock every + // administrator out of the web UI until users.xml is hand-edited + if ("admin".equals(userId) && dto.getRoles() != null && !dto.getRoles().contains(Authentication.ROLE_ADMIN)) { + return Response.status(Status.BAD_REQUEST) + .entity("The admin user must keep the " + Authentication.ROLE_ADMIN + " role.").build(); + } + try { + synchronized (org.opennms.netmgt.config.GroupFactory.class) { + final User existing = m_userManager.getUser(userId); + if (existing == null) { + return Response.status(Status.NOT_FOUND).entity("User " + userId + " was not found.").build(); + } + try { + validateDtoFields(dto, existing); + } catch (final IllegalArgumentException e) { + return Response.status(Status.BAD_REQUEST).entity(e.getMessage()).build(); + } + final User updated = copyOf(existing); + applyDto(updated, dto); + m_userManager.saveUser(userId, updated); + } + return Response.noContent().build(); + } catch (final Exception e) { + return serverError("Can't update user: %s", e); + } + } + + @Override + public Response setPassword(final SecurityContext securityContext, final String userId, final UserPasswordRequest request) { + assertAdmin(securityContext); + if (request == null || isBlank(request.getPassword())) { + return Response.status(Status.BAD_REQUEST).entity("A password is required.").build(); + } + try { + synchronized (org.opennms.netmgt.config.GroupFactory.class) { + final User existing = m_userManager.getUser(userId); + if (existing == null) { + return Response.status(Status.NOT_FOUND).entity("User " + userId + " was not found.").build(); + } + final User updated = copyOf(existing); + updated.setPassword(m_userManager.encryptedPassword(request.getPassword(), true), Boolean.TRUE); + m_userManager.saveUser(userId, updated); + } + LOG.info("Password changed for user {} by {}", userId, principal(securityContext)); + return Response.noContent().build(); + } catch (final Exception e) { + return serverError("Can't change password: %s", e); + } + } + + @Override + public Response renameUser(final SecurityContext securityContext, final String userId, final UserRenameRequest request) { + assertAdmin(securityContext); + if (request == null || isBlank(request.getNewUserId())) { + return Response.status(Status.BAD_REQUEST).entity("A new-user-id is required.").build(); + } + if (PROTECTED_USERS.contains(userId)) { + return Response.status(Status.BAD_REQUEST).entity("The system user " + userId + " cannot be renamed.").build(); + } + final String newUserId = request.getNewUserId().trim(); + final String userIdProblem = validateUserId(newUserId); + if (userIdProblem != null) { + return Response.status(Status.BAD_REQUEST).entity(userIdProblem).build(); + } + try { + synchronized (org.opennms.netmgt.config.GroupFactory.class) { + if (!m_userManager.hasUser(userId)) { + return Response.status(Status.NOT_FOUND).entity("User " + userId + " was not found.").build(); + } + if (m_userManager.hasUser(newUserId)) { + return Response.status(Status.BAD_REQUEST).entity("User " + newUserId + " already exists.").build(); + } + m_userManager.renameUser(userId, newUserId); + } + LOG.info("User {} renamed to {} by {}", userId, newUserId, principal(securityContext)); + return Response.noContent().build(); + } catch (final Exception e) { + return serverError("Can't rename user: %s", e); + } + } + + @Override + public Response deleteUser(final SecurityContext securityContext, final String userId) { + assertAdmin(securityContext); + if (PROTECTED_USERS.contains(userId)) { + return Response.status(Status.BAD_REQUEST).entity("The system user " + userId + " cannot be deleted.").build(); + } + try { + synchronized (org.opennms.netmgt.config.GroupFactory.class) { + if (!m_userManager.hasUser(userId)) { + return Response.status(Status.NOT_FOUND).entity("User " + userId + " was not found.").build(); + } + // GroupManager.deleteUser strips memberships and schedules but + // leaves role supervisors dangling, silently killing the + // supervisor fallback for those rotas + final List supervisedRoles = new ArrayList<>(); + for (final org.opennms.netmgt.config.groups.Role role : m_groupManager.getRoles()) { + if (userId.equals(role.getSupervisor())) { + supervisedRoles.add(role.getName()); + } + } + if (!supervisedRoles.isEmpty()) { + return Response.status(Status.BAD_REQUEST).entity("User " + userId + + " is the supervisor of on-call role(s) " + String.join(", ", supervisedRoles) + + "; assign a different supervisor first.").build(); + } + m_userManager.deleteUser(userId); + } + LOG.info("User {} deleted by {}", userId, principal(securityContext)); + return Response.noContent().build(); + } catch (final Exception e) { + return serverError("Can't delete user: %s", e); + } + } + + private UserDto toDto(final User user) { + final UserDto dto = new UserDto(); + dto.setUserId(user.getUserId()); + dto.setFullName(user.getFullName().orElse(null)); + dto.setUserComments(user.getUserComments().orElse(null)); + dto.setEmail(contactInfo(user, ContactType.email)); + dto.setPagerEmail(contactInfo(user, ContactType.pagerEmail)); + dto.setTuiPin(user.getTuiPin().orElse(null)); + dto.setTimeZoneId(user.getTimeZoneId().map(Objects::toString).orElse(null)); + dto.setDutySchedules(new ArrayList<>(user.getDutySchedules())); + dto.setRoles(new ArrayList<>(user.getRoles())); + dto.setReadOnly(user.getRoles().contains(Authentication.ROLE_READONLY)); + return dto; + } + + /** Detached copy so mutations never touch the manager's live object. */ + private static User copyOf(final User user) { + final User copy = new User(); + copy.setUserId(user.getUserId()); + copy.setFullName(user.getFullName().orElse(null)); + copy.setUserComments(user.getUserComments().orElse(null)); + final Password password = user.getPassword(); + if (password != null) { + copy.setPassword(password.getEncryptedPassword(), password.getSalt()); + } + for (final Contact contact : user.getContacts()) { + final Contact contactCopy = new Contact(contact.getType()); + contactCopy.setInfo(contact.getInfo().orElse(null)); + contactCopy.setServiceProvider(contact.getServiceProvider().orElse(null)); + copy.getContacts().add(contactCopy); + } + copy.setDutySchedules(new ArrayList<>(user.getDutySchedules())); + copy.setRoles(new ArrayList<>(user.getRoles())); + copy.setTuiPin(user.getTuiPin().orElse(null)); + copy.setTimeZoneId(user.getTimeZoneId().orElse(null)); + return copy; + } + + /** + * Validates every field of the request BEFORE anything is applied, so a + * rejected request cannot leave partial state anywhere. + */ + private static void validateDtoFields(final UserDto dto, final User existing) { + if (dto.getUserComments() != null && INVALID_COMMENTS.matcher(dto.getUserComments()).find() + && (existing == null || !dto.getUserComments().equals(existing.getUserComments().orElse(null)))) { + throw new IllegalArgumentException("The comments must not contain any HTML markup."); + } + final String timeZoneId = trimToNull(dto.getTimeZoneId()); + if (timeZoneId != null) { + try { + java.time.ZoneId.of(timeZoneId); + } catch (final RuntimeException e) { + throw new IllegalArgumentException("Invalid time-zone-id: " + timeZoneId); + } + } + if (dto.getRoles() != null) { + for (final String role : dto.getRoles()) { + if (!Authentication.isValidRole(role)) { + throw new IllegalArgumentException("Unknown security role: " + role); + } + } + } + if (dto.getDutySchedules() != null) { + // strings already stored on the record are preserved as-is so + // hand-edited files never make a user uneditable; only entries + // new to this request must pass validation + final Set preExisting = existing == null + ? Set.of() : new java.util.LinkedHashSet<>(existing.getDutySchedules()); + for (final String schedule : dto.getDutySchedules()) { + if (!preExisting.contains(schedule)) { + validateDutySchedule(schedule); + } + } + } + } + + /** + * Applies the pre-validated DTO. Only the exposed contact types (email, + * pagerEmail) are touched; every other contact — XMPP, microblog, phones, + * paging services — and the password survive untouched, so a v2 update + * can never corrupt hand-maintained users.xml entries. List fields left + * out of the request body arrive as null and are preserved. + */ + private static void applyDto(final User user, final UserDto dto) { + // omitted (null) fields are preserved, matching the groups and + // on-call services; an empty string clears a field + if (dto.getFullName() != null) { + user.setFullName(trimToNull(dto.getFullName())); + } + if (dto.getUserComments() != null) { + user.setUserComments(trimToNull(dto.getUserComments())); + } + if (dto.getTuiPin() != null) { + user.setTuiPin(trimToNull(dto.getTuiPin())); + } + if (dto.getTimeZoneId() != null) { + final String timeZoneId = trimToNull(dto.getTimeZoneId()); + if (timeZoneId == null) { + user.setTimeZoneId((java.time.ZoneId) null); + } else { + user.setTimeZoneId(timeZoneId); + } + } + if (dto.getEmail() != null) { + setContact(user, ContactType.email, dto.getEmail()); + } + if (dto.getPagerEmail() != null) { + setContact(user, ContactType.pagerEmail, dto.getPagerEmail()); + } + if (dto.getDutySchedules() != null) { + user.setDutySchedules(new ArrayList<>(dto.getDutySchedules())); + } + if (dto.getRoles() != null) { + user.setRoles(new ArrayList<>(dto.getRoles())); + } + } + + /** + * A failed save leaves the new user in UserManager's in-memory map + * (_writeUser puts before _saveCurrent), which would 400 every retry as + * "already exists". Best effort: remove the phantom again. + */ + private void rollbackPhantomUser(final String userId) { + try { + if (m_userManager.hasUser(userId)) { + m_userManager.deleteUser(userId); + } + } catch (final Exception rollbackFailure) { + LOG.warn("Could not roll back partially created user {}", userId, rollbackFailure); + } + } + + /** Returns a problem description, or null when the user id is acceptable. */ + private static String validateUserId(final String userId) { + if (INVALID_USER_ID.matcher(userId).find()) { + return "The user-id must not contain markup, whitespace, or the characters : / \\ % ? #"; + } + if (".".equals(userId) || "..".equals(userId)) { + return "The user-id must not be a dot segment."; + } + return null; + } + + /** + * Duty schedules are stored as strings like MoWeFr800-1700 and parsed with + * unchecked exceptions all over notifd/group scheduling — an invalid string + * saved here would break duty evaluation at runtime. + */ + private static void validateDutySchedule(final String schedule) { + final Matcher matcher = schedule == null ? null : DUTY_SCHEDULE.matcher(schedule); + if (matcher == null || !matcher.matches()) { + throw new IllegalArgumentException("Invalid duty schedule '" + schedule + "': expected day tokens followed by begin-end military times, e.g. MoWeFr800-1700"); + } + final int begin = Integer.parseInt(matcher.group(2)); + final int end = Integer.parseInt(matcher.group(3)); + if (begin > 2359 || end > 2359 || begin % 100 > 59 || end % 100 > 59) { + throw new IllegalArgumentException("Invalid duty schedule '" + schedule + "': times must be military clock values between 0 and 2359"); + } + // DutySchedule.isInSchedule compares within one calendar day, so an + // overnight range can never match and would silently disable the + // schedule; require two rows (e.g. MoTu2000-2359 + TuWe0-800) instead + if (begin > end) { + throw new IllegalArgumentException("Invalid duty schedule '" + schedule + "': the begin time must not be after the end time; split overnight coverage into two schedules"); + } + } + + private static String contactInfo(final User user, final ContactType type) { + return user.getContacts().stream() + .filter(c -> type.name().equals(c.getType())) + .findFirst() + .flatMap(Contact::getInfo) + .filter(info -> !info.isEmpty()) + .orElse(null); + } + + private static void setContact(final User user, final ContactType type, final String value) { + final Optional existing = user.getContacts().stream() + .filter(c -> type.name().equals(c.getType())) + .findFirst(); + final String trimmed = trimToNull(value); + if (existing.isPresent()) { + existing.get().setInfo(trimmed == null ? "" : trimmed); + } else if (trimmed != null) { + final Contact contact = new Contact(type.name()); + contact.setInfo(trimmed); + user.getContacts().add(contact); + } + } + + private static void assertAdmin(final SecurityContext securityContext) { + if (securityContext == null || !securityContext.isUserInRole(Authentication.ROLE_ADMIN)) { + throw new javax.ws.rs.WebApplicationException( + Response.status(Status.FORBIDDEN).entity("User management requires the admin role.").build()); + } + } + + private static String principal(final SecurityContext securityContext) { + return securityContext.getUserPrincipal() == null ? "?" : securityContext.getUserPrincipal().getName(); + } + + private Response serverError(final String format, final Exception e) { + if (e instanceof IllegalArgumentException) { + return Response.status(Status.BAD_REQUEST).entity(e.getMessage()).build(); + } + LOG.error(String.format(format, e.getMessage()), e); + return Response.status(Status.INTERNAL_SERVER_ERROR).entity(String.format(format, e.getMessage())).build(); + } + + private static boolean isBlank(final String value) { + return value == null || value.isBlank(); + } + + private static String trimToNull(final String value) { + if (value == null) { + return null; + } + final String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/api/UsersRestApi.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/api/UsersRestApi.java new file mode 100644 index 000000000000..2fb48440c6bb --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/api/UsersRestApi.java @@ -0,0 +1,97 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.web.rest.v2.api; + +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.SecurityContext; + +import org.opennms.web.rest.v2.model.UserDto; +import org.opennms.web.rest.v2.model.UserPasswordRequest; +import org.opennms.web.rest.v2.model.UserRenameRequest; +import org.opennms.web.rest.v2.model.UserWriteRequest; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Versioned user management API backed by users.xml. Password hashes are + * never returned; admin-only (enforced by Spring Security and in-code). + */ +@Path("users") +@Tag(name = "Users", description = "User Management API") +public interface UsersRestApi { + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "List all users", operationId = "listUsers") + Response listUsers(@Context SecurityContext securityContext); + + @GET + @Path("{userId}") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Get one user", operationId = "getUser") + Response getUser(@Context SecurityContext securityContext, @PathParam("userId") String userId); + + @GET + @Path("available-roles") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "List the assignable security roles", operationId = "listAvailableRoles") + Response listAvailableRoles(@Context SecurityContext securityContext); + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Operation(summary = "Create a user", operationId = "createUser") + Response createUser(@Context SecurityContext securityContext, UserWriteRequest request); + + @PUT + @Path("{userId}") + @Consumes(MediaType.APPLICATION_JSON) + @Operation(summary = "Update a user's details (contact types and password not covered here are preserved)", operationId = "updateUser") + Response updateUser(@Context SecurityContext securityContext, @PathParam("userId") String userId, UserDto user); + + @PUT + @Path("{userId}/password") + @Consumes(MediaType.APPLICATION_JSON) + @Operation(summary = "Set a user's password (stored salted)", operationId = "setUserPassword") + Response setPassword(@Context SecurityContext securityContext, @PathParam("userId") String userId, UserPasswordRequest request); + + @POST + @Path("{userId}/rename") + @Consumes(MediaType.APPLICATION_JSON) + @Operation(summary = "Rename a user (also updates group memberships)", operationId = "renameUser") + Response renameUser(@Context SecurityContext securityContext, @PathParam("userId") String userId, UserRenameRequest request); + + @DELETE + @Path("{userId}") + @Operation(summary = "Delete a user (also removes group memberships; admin and rtc are protected)", operationId = "deleteUser") + Response deleteUser(@Context SecurityContext securityContext, @PathParam("userId") String userId); +} diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/UserDto.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/UserDto.java new file mode 100644 index 000000000000..688a3653ece6 --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/UserDto.java @@ -0,0 +1,152 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.web.rest.v2.model; + +import java.util.List; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; + +/** + * A user as exposed by the v2 user management API. Field names mirror + * users.xml where they overlap; the password hash is deliberately never part + * of this representation, and contact types the API does not expose (XMPP, + * microblog, phones, pager PINs) are preserved server-side on update. + */ +@XmlRootElement(name = "user") +@XmlAccessorType(XmlAccessType.FIELD) +public class UserDto { + + @XmlElement(name = "user-id") + private String userId; + + @XmlElement(name = "full-name") + private String fullName; + + @XmlElement(name = "user-comments") + private String userComments; + + @XmlElement(name = "email") + private String email; + + @XmlElement(name = "pager-email") + private String pagerEmail; + + @XmlElement(name = "tui-pin") + private String tuiPin; + + @XmlElement(name = "time-zone-id") + private String timeZoneId; + + // null (not empty) defaults: a request body that omits these keys + // deserializes to null, which update semantics treat as "preserve" + @XmlElement(name = "duty-schedule") + private List dutySchedules; + + @XmlElement(name = "role") + private List roles; + + @XmlElement(name = "read-only") + private Boolean readOnly; + + public String getUserId() { + return userId; + } + + public void setUserId(final String userId) { + this.userId = userId; + } + + public String getFullName() { + return fullName; + } + + public void setFullName(final String fullName) { + this.fullName = fullName; + } + + public String getUserComments() { + return userComments; + } + + public void setUserComments(final String userComments) { + this.userComments = userComments; + } + + public String getEmail() { + return email; + } + + public void setEmail(final String email) { + this.email = email; + } + + public String getPagerEmail() { + return pagerEmail; + } + + public void setPagerEmail(final String pagerEmail) { + this.pagerEmail = pagerEmail; + } + + public String getTuiPin() { + return tuiPin; + } + + public void setTuiPin(final String tuiPin) { + this.tuiPin = tuiPin; + } + + public String getTimeZoneId() { + return timeZoneId; + } + + public void setTimeZoneId(final String timeZoneId) { + this.timeZoneId = timeZoneId; + } + + public List getDutySchedules() { + return dutySchedules; + } + + public void setDutySchedules(final List dutySchedules) { + this.dutySchedules = dutySchedules; + } + + public List getRoles() { + return roles; + } + + public void setRoles(final List roles) { + this.roles = roles; + } + + public Boolean getReadOnly() { + return readOnly; + } + + public void setReadOnly(final Boolean readOnly) { + this.readOnly = readOnly; + } +} diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/UserPasswordRequest.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/UserPasswordRequest.java new file mode 100644 index 000000000000..da123fc68779 --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/UserPasswordRequest.java @@ -0,0 +1,43 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.web.rest.v2.model; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; + +@XmlRootElement(name = "user-password-request") +@XmlAccessorType(XmlAccessType.FIELD) +public class UserPasswordRequest { + + @XmlElement(name = "password") + private String password; + + public String getPassword() { + return password; + } + + public void setPassword(final String password) { + this.password = password; + } +} diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/UserRenameRequest.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/UserRenameRequest.java new file mode 100644 index 000000000000..6d08c3664ef3 --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/UserRenameRequest.java @@ -0,0 +1,43 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.web.rest.v2.model; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; + +@XmlRootElement(name = "user-rename-request") +@XmlAccessorType(XmlAccessType.FIELD) +public class UserRenameRequest { + + @XmlElement(name = "new-user-id") + private String newUserId; + + public String getNewUserId() { + return newUserId; + } + + public void setNewUserId(final String newUserId) { + this.newUserId = newUserId; + } +} diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/UserWriteRequest.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/UserWriteRequest.java new file mode 100644 index 000000000000..525ec0793052 --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/UserWriteRequest.java @@ -0,0 +1,49 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.web.rest.v2.model; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; + +/** + * Create request for the v2 user management API: the UserDto fields plus the + * initial password, which is hashed (salted) before it reaches users.xml. + * Password changes on existing users go through the dedicated password + * endpoint instead. + */ +@XmlRootElement(name = "user-create-request") +@XmlAccessorType(XmlAccessType.FIELD) +public class UserWriteRequest extends UserDto { + + @XmlElement(name = "password") + private String password; + + public String getPassword() { + return password; + } + + public void setPassword(final String password) { + this.password = password; + } +} diff --git a/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template.json b/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template.json index d5a81390467e..a715f471e540 100644 --- a/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template.json +++ b/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template.json @@ -404,7 +404,7 @@ { "id": "manageUsers", "name": "Manage Users", - "url": "admin/userGroupView/users/list.jsp", + "url": "ui/index.html#/admin/users", "locationMatch": "", "roles": null }, diff --git a/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/UsersRestServiceIT.java b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/UsersRestServiceIT.java new file mode 100644 index 000000000000..c78725922f35 --- /dev/null +++ b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/UsersRestServiceIT.java @@ -0,0 +1,417 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.web.rest.v2; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import javax.ws.rs.core.MediaType; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.opennms.core.test.MockLogAppender; +import org.opennms.core.test.OpenNMSJUnit4ClassRunner; +import org.opennms.core.test.db.annotations.JUnitTemporaryDatabase; +import org.opennms.core.test.rest.AbstractSpringJerseyRestTestCase; +import org.opennms.netmgt.config.UserManager; +import org.opennms.netmgt.config.users.Contact; +import org.opennms.netmgt.config.users.User; +import org.opennms.test.JUnitConfigurationEnvironment; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.web.WebAppConfiguration; + +@RunWith(OpenNMSJUnit4ClassRunner.class) +@WebAppConfiguration +@ContextConfiguration(locations={ + "classpath:/META-INF/opennms/applicationContext-soa.xml", + "classpath:/META-INF/opennms/applicationContext-commonConfigs.xml", + "classpath:/META-INF/opennms/applicationContext-minimal-conf.xml", + "classpath:/META-INF/opennms/applicationContext-dao.xml", + "classpath:/META-INF/opennms/applicationContext-mockConfigManager.xml", + "classpath*:/META-INF/opennms/component-service.xml", + "classpath*:/META-INF/opennms/component-dao.xml", + "classpath:/META-INF/opennms/applicationContext-databasePopulator.xml", + "classpath:/META-INF/opennms/mockEventIpcManager.xml", + "file:src/main/webapp/WEB-INF/applicationContext-svclayer.xml", + "file:src/main/webapp/WEB-INF/applicationContext-cxf-common.xml", + // in-memory user/group managers so users.xml is never touched + "classpath:/META-INF/opennms/applicationContext-mock-usergroup.xml", + "classpath:/applicationContext-rest-test.xml" +}) +@JUnitConfigurationEnvironment(systemProperties = "org.opennms.timeseries.strategy=integration") +@JUnitTemporaryDatabase +public class UsersRestServiceIT extends AbstractSpringJerseyRestTestCase { + + @Autowired + private UserManager m_userManager; + + @Autowired + private org.opennms.netmgt.config.GroupManager m_groupManager; + + public UsersRestServiceIT() { + super(CXF_REST_V2_CONTEXT_PATH); + } + + @Override + protected void beforeServletStart() { + MockLogAppender.setupLogging(); + } + + @Test + public void testListNeverContainsPasswordHashes() throws Exception { + final String json = getJson("/users", 200); + final JSONArray users = new JSONArray(json); + assertTrue(users.length() >= 1); + assertEquals("admin", users.getJSONObject(0).getString("user-id")); + // the v1 API leaks hashes to admins; the v2 contract is that no + // response ever carries the password in any form + assertFalse(json.contains("password")); + assertFalse(json.contains("21232F29")); + } + + @Test + public void testGetUser() throws Exception { + final JSONObject admin = new JSONObject(getJson("/users/admin", 200)); + assertEquals("admin", admin.getString("user-id")); + sendRequest(GET, "/users/idontexist", 404); + } + + @Test + public void testAvailableRoles() throws Exception { + final JSONArray roles = new JSONArray(getJson("/users/available-roles", 200)); + boolean foundAdmin = false; + for (int i = 0; i < roles.length(); i++) { + foundAdmin |= "ROLE_ADMIN".equals(roles.getString(i)); + } + assertTrue(foundAdmin); + } + + @Test + public void testCreateLifecycle() throws Exception { + final String body = "{\"user-id\":\"junituser\",\"password\":\"S3cret!pw\",\"full-name\":\"JUnit User\"," + + "\"email\":\"junit@example.com\",\"pager-email\":\"junit-pager@example.com\"," + + "\"duty-schedule\":[\"MoWeFr800-1700\"],\"role\":[\"ROLE_USER\"]}"; + sendData(POST, MediaType.APPLICATION_JSON, "/users", body, 201); + + final JSONObject created = new JSONObject(getJson("/users/junituser", 200)); + assertEquals("JUnit User", created.getString("full-name")); + assertEquals("junit@example.com", created.getString("email")); + assertEquals("junit-pager@example.com", created.getString("pager-email")); + assertEquals("MoWeFr800-1700", created.getJSONArray("duty-schedule").getString(0)); + assertEquals("ROLE_USER", created.getJSONArray("role").getString(0)); + assertTrue(m_userManager.comparePasswords("junituser", "S3cret!pw")); + + // creating the same user again must be rejected + sendData(POST, MediaType.APPLICATION_JSON, "/users", body, 400); + + sendRequest(DELETE, "/users/junituser", 204); + sendRequest(GET, "/users/junituser", 404); + } + + @Test + public void testCreateValidation() throws Exception { + // missing password + sendData(POST, MediaType.APPLICATION_JSON, "/users", "{\"user-id\":\"nopass\"}", 400); + // markup in the user id + sendData(POST, MediaType.APPLICATION_JSON, "/users", "{\"user-id\":\"bad\"}", 400); + sendData(PUT, MediaType.APPLICATION_JSON, "/users/markup", + "{\"user-id\":\"markup\",\"user-comments\":\"plain text\"}", 204); + sendRequest(DELETE, "/users/markup", 204); + } + + @Test + public void testHandEditedMarkupCommentStaysEditable() throws Exception { + // a hand-edited users.xml comment with markup characters must not + // make the user uneditable when it round-trips unchanged + final User user = new User(); + user.setUserId("legacycomment"); + user.setPassword(m_userManager.encryptedPassword("pw", true), Boolean.TRUE); + user.setUserComments("Bob's R&D user"); + m_userManager.saveUser("legacycomment", user); + + sendData(PUT, MediaType.APPLICATION_JSON, "/users/legacycomment", + "{\"user-id\":\"legacycomment\",\"user-comments\":\"Bob's R&D user\",\"full-name\":\"Touched\"}", 204); + final JSONObject after = new JSONObject(getJson("/users/legacycomment", 200)); + assertEquals("Bob's R&D user", after.getString("user-comments")); + assertEquals("Touched", after.getString("full-name")); + sendRequest(DELETE, "/users/legacycomment", 204); + } + + @Test + public void testDeleteBlockedWhileSupervisingOnCallRole() throws Exception { + sendData(POST, MediaType.APPLICATION_JSON, "/users", + "{\"user-id\":\"rolesuper\",\"password\":\"pw\"}", 201); + final org.opennms.netmgt.config.groups.Role role = new org.opennms.netmgt.config.groups.Role(); + role.setName("super-role"); + role.setMembershipGroup("Admin"); + role.setSupervisor("rolesuper"); + m_groupManager.saveRole(role); + + // deleting the supervisor would leave the rota's fallback dangling + sendData(DELETE, MediaType.APPLICATION_JSON, "/users/rolesuper", "", 400); + + m_groupManager.deleteRole("super-role"); + sendRequest(DELETE, "/users/rolesuper", 204); + } + + @Test + public void testBodyPathUserIdMismatchRejected() throws Exception { + sendData(PUT, MediaType.APPLICATION_JSON, "/users/admin", + "{\"user-id\":\"somebody-else\",\"full-name\":\"X\"}", 400); + } + + @Test + public void testForbiddenForNonAdmin() throws Exception { + setUser("nobody", new String[]{ "ROLE_USER" }); + try { + sendRequest(GET, "/users", 403); + sendData(POST, MediaType.APPLICATION_JSON, "/users", "{\"user-id\":\"x\",\"password\":\"x\"}", 403); + sendRequest(DELETE, "/users/admin", 403); + } finally { + setUser("admin", new String[]{ "ROLE_ADMIN" }); + } + } + + private String getJson(final String url, final int expectedStatus) throws Exception { + final MockHttpServletRequest request = createRequest(GET, url); + request.addHeader("Accept", MediaType.APPLICATION_JSON); + return sendRequest(request, expectedStatus); + } +} diff --git a/opennms-webapp/src/main/webapp/WEB-INF/applicationContext-spring-security.xml b/opennms-webapp/src/main/webapp/WEB-INF/applicationContext-spring-security.xml index 4829fe6357f7..f2c0fd508618 100644 --- a/opennms-webapp/src/main/webapp/WEB-INF/applicationContext-spring-security.xml +++ b/opennms-webapp/src/main/webapp/WEB-INF/applicationContext-spring-security.xml @@ -193,6 +193,13 @@ + + + + + + + diff --git a/smoke-test/src/test/java/org/opennms/smoketest/MenuHeaderIT.java b/smoke-test/src/test/java/org/opennms/smoketest/MenuHeaderIT.java index 5949936dcf44..f221ee537a85 100644 --- a/smoke-test/src/test/java/org/opennms/smoketest/MenuHeaderIT.java +++ b/smoke-test/src/test/java/org/opennms/smoketest/MenuHeaderIT.java @@ -162,7 +162,8 @@ public void testMenuEntries() throws Exception { // User Management Menu clickMenuItem("User Management", "Manage Users"); - wait.until(ExpectedConditions.visibilityOfElementLocated(By.xpath("//ol[@class='breadcrumb']/li[contains(text()[normalize-space()], 'User List')]"))); + // now the Vue page (ui/index.html) + wait.until(ExpectedConditions.visibilityOfElementLocated(By.xpath("//h1[@class='page-title' and text()='Manage Users']"))); clickMenuItem("User Management", "Manage Groups"); wait.until(ExpectedConditions.visibilityOfElementLocated(By.xpath("//ol[@class='breadcrumb']/li[contains(text()[normalize-space()], 'Group List')]"))); diff --git a/ui/src/components/ManageUsers/UserEditorDialog.vue b/ui/src/components/ManageUsers/UserEditorDialog.vue new file mode 100644 index 000000000000..277f22fe7ecf --- /dev/null +++ b/ui/src/components/ManageUsers/UserEditorDialog.vue @@ -0,0 +1,277 @@ + + + + + diff --git a/ui/src/components/ManageUsers/UserPasswordDialog.vue b/ui/src/components/ManageUsers/UserPasswordDialog.vue new file mode 100644 index 000000000000..9133410fce05 --- /dev/null +++ b/ui/src/components/ManageUsers/UserPasswordDialog.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/ui/src/components/ManageUsers/UserRenameDialog.vue b/ui/src/components/ManageUsers/UserRenameDialog.vue new file mode 100644 index 000000000000..534f1c74c3b4 --- /dev/null +++ b/ui/src/components/ManageUsers/UserRenameDialog.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/ui/src/components/ManageUsers/UsersHelpPanel.vue b/ui/src/components/ManageUsers/UsersHelpPanel.vue new file mode 100644 index 000000000000..c1dfe7ba3257 --- /dev/null +++ b/ui/src/components/ManageUsers/UsersHelpPanel.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/ui/src/components/ManageUsers/UsersTable.vue b/ui/src/components/ManageUsers/UsersTable.vue new file mode 100644 index 000000000000..d3e27d4e1ca2 --- /dev/null +++ b/ui/src/components/ManageUsers/UsersTable.vue @@ -0,0 +1,244 @@ + + + + + diff --git a/ui/src/containers/ManageUsers.vue b/ui/src/containers/ManageUsers.vue new file mode 100644 index 000000000000..b661a4e3ccac --- /dev/null +++ b/ui/src/containers/ManageUsers.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/ui/src/lib/adminValidation.ts b/ui/src/lib/adminValidation.ts new file mode 100644 index 000000000000..c99e0cf35c86 --- /dev/null +++ b/ui/src/lib/adminValidation.ts @@ -0,0 +1,80 @@ +/// +/// Licensed to The OpenNMS Group, Inc (TOG) under one or more +/// contributor license agreements. See the LICENSE.md file +/// distributed with this work for additional information +/// regarding copyright ownership. +/// +/// TOG licenses this file to You under the GNU Affero General +/// Public License Version 3 (the "License") or (at your option) +/// any later version. You may not use this file except in +/// compliance with the License. You may obtain a copy of the +/// License at: +/// +/// https://www.gnu.org/licenses/agpl-3.0.txt +/// +/// Unless required by applicable law or agreed to in writing, +/// software distributed under the License is distributed on an +/// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +/// either express or implied. See the License for the specific +/// language governing permissions and limitations under the +/// License. +/// + +// Client-side mirrors of the /api/v2 admin validation rules so forms can flag +// problems before submitting. These must stay in sync with UsersRestService, +// GroupsRestService and OnCallRolesRestService (INVALID_NAME/INVALID_COMMENTS). + +const INVALID_NAME = /[&<>"`':/\\%?#\s]/ +const INVALID_COMMENTS = /[&<>"`']/ +const EMAIL_SHAPE = /[^\s@]+@[^\s@]+/ + +/** + * Validates a user-id, group name or on-call role name. + * Returns a problem description, or null when the value is acceptable. + * Emptiness is not checked here; required-ness is a per-form concern. + */ +export const validateAdminName = (value: string, label: string): string | null => { + const trimmed = value.trim() + if (!trimmed) { + return null + } + if (INVALID_NAME.test(trimmed)) { + return `The ${label} must not contain markup, whitespace, or the characters : / \\ % ? #` + } + if (trimmed === '.' || trimmed === '..') { + return `The ${label} must not be a dot segment.` + } + return null +} + +/** Group comments may not contain HTML markup characters. */ +export const validateAdminComments = (value: string): string | null => { + if (value && INVALID_COMMENTS.test(value)) { + return 'The comments must not contain the characters & < > " ` \'' + } + return null +} + +/** + * Names containing / \ or % cannot be addressed as a URL path segment (the + * security filter rejects their encoded forms), so per-item API operations + * are unavailable for such hand-edited legacy entries. + */ +export const isPathAddressable = (name: string): boolean => !/[/\\%]/.test(name) + +/** + * Loose shape check: every comma-separated recipient must contain a + * local@domain somewhere, which also accepts RFC-5322 display-name forms + * like `Bill Smith `. + */ +export const validateEmailShape = (value: string, label: string): string | null => { + const trimmed = value.trim() + if (!trimmed) { + return null + } + const parts = trimmed.split(',').map((part) => part.trim()) + if (parts.some((part) => !part || !EMAIL_SHAPE.test(part))) { + return `The ${label} must look like an email address (name@domain).` + } + return null +} diff --git a/ui/src/main/router/index.ts b/ui/src/main/router/index.ts index 52a1b1b07ba4..57c10adb438f 100644 --- a/ui/src/main/router/index.ts +++ b/ui/src/main/router/index.ts @@ -158,6 +158,25 @@ const router = createRouter({ } } }, + { + path: '/admin/users', + name: 'Manage Users', + component: () => import('@/containers/ManageUsers.vue'), + beforeEnter: (to, from) => { + const checkRoles = () => { + if (!adminRole.value) { + showSnackBar({ msg: 'Must be admin to manage users.' }) + router.push(from.path) + } + } + + if (rolesAreLoaded.value) { + checkRoles() + } else { + whenever(rolesAreLoaded, () => checkRoles()) + } + } + }, { path: '/map', name: 'Map', diff --git a/ui/src/services/index.ts b/ui/src/services/index.ts index 8b8c60f273ea..c19b572413fc 100644 --- a/ui/src/services/index.ts +++ b/ui/src/services/index.ts @@ -74,6 +74,15 @@ import { setUsageStatisticsStatus } from './usageStatisticsService' import { addZenithRegistration, getZenithRegistrations } from './zenithConnectService' +import { + createManagedUser, + deleteManagedUser, + getAvailableUserRoles, + getManagedUsers, + renameManagedUser, + setManagedUserPassword, + updateManagedUser +} from './userAdminService' export default { search, @@ -135,5 +144,12 @@ export default { setUsageStatisticsStatus, addZenithRegistration, getZenithRegistrations, - performLogout + performLogout, + createManagedUser, + deleteManagedUser, + getAvailableUserRoles, + getManagedUsers, + renameManagedUser, + setManagedUserPassword, + updateManagedUser } diff --git a/ui/src/services/userAdminService.ts b/ui/src/services/userAdminService.ts new file mode 100644 index 000000000000..d21ae5bd908c --- /dev/null +++ b/ui/src/services/userAdminService.ts @@ -0,0 +1,144 @@ +/// +/// Licensed to The OpenNMS Group, Inc (TOG) under one or more +/// contributor license agreements. See the LICENSE.md file +/// distributed with this work for additional information +/// regarding copyright ownership. +/// +/// TOG licenses this file to You under the GNU Affero General +/// Public License Version 3 (the "License") or (at your option) +/// any later version. You may not use this file except in +/// compliance with the License. You may obtain a copy of the +/// License at: +/// +/// https://www.gnu.org/licenses/agpl-3.0.txt +/// +/// Unless required by applicable law or agreed to in writing, +/// software distributed under the License is distributed on an +/// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +/// either express or implied. See the License for the specific +/// language governing permissions and limitations under the +/// License. +/// + +import useSnackbar from '@/composables/useSnackbar' +import useSpinner from '@/composables/useSpinner' +import { ManagedUser, ManagedUserCreate } from '@/types/userAdmin' +import { v2 } from './axiosInstances' + +const { showSnackBar } = useSnackbar() +const { startSpinner, stopSpinner } = useSpinner() +const endpoint = '/users' + +const errorMessage = (err: any, fallback: string): string => { + const detail = err?.response?.data + return typeof detail === 'string' && detail ? detail : fallback +} + +// null on failure (not []) so callers can keep showing the previous list +const getManagedUsers = async (): Promise => { + try { + startSpinner() + const resp = await v2.get(endpoint) + return Array.isArray(resp.data) ? resp.data : [] + } catch (_err) { + showSnackBar({ msg: 'Failed to load users.' }) + return null + } finally { + stopSpinner() + } +} + +const getAvailableUserRoles = async (): Promise => { + try { + const resp = await v2.get(`${endpoint}/available-roles`) + return Array.isArray(resp.data) ? resp.data : [] + } catch (_err) { + showSnackBar({ msg: 'Failed to load available roles.' }) + return [] + } +} + +const createManagedUser = async (user: ManagedUserCreate): Promise => { + try { + startSpinner() + await v2.post(endpoint, user) + showSnackBar({ msg: `User '${user['user-id']}' created.` }) + return null + } catch (err: any) { + const msg = errorMessage(err, `Failed to create user '${user['user-id']}'.`) + showSnackBar({ msg, error: true }) + return msg + } finally { + stopSpinner() + } +} + +const updateManagedUser = async (user: ManagedUser): Promise => { + try { + startSpinner() + await v2.put(`${endpoint}/${encodeURIComponent(user['user-id'])}`, user) + showSnackBar({ msg: `User '${user['user-id']}' updated.` }) + return null + } catch (err: any) { + const msg = errorMessage(err, `Failed to update user '${user['user-id']}'.`) + showSnackBar({ msg, error: true }) + return msg + } finally { + stopSpinner() + } +} + +const setManagedUserPassword = async (userId: string, password: string): Promise => { + try { + startSpinner() + await v2.put(`${endpoint}/${encodeURIComponent(userId)}/password`, { password }) + showSnackBar({ msg: `Password changed for '${userId}'.` }) + return null + } catch (err: any) { + const msg = errorMessage(err, `Failed to change the password for '${userId}'.`) + showSnackBar({ msg, error: true }) + return msg + } finally { + stopSpinner() + } +} + +const renameManagedUser = async (userId: string, newUserId: string): Promise => { + try { + startSpinner() + await v2.post(`${endpoint}/${encodeURIComponent(userId)}/rename`, { 'new-user-id': newUserId }) + showSnackBar({ msg: `User '${userId}' renamed to '${newUserId}'.` }) + return null + } catch (err: any) { + const msg = errorMessage(err, `Failed to rename user '${userId}'.`) + showSnackBar({ msg, error: true }) + return msg + } finally { + stopSpinner() + } +} + +const deleteManagedUser = async (userId: string): Promise => { + try { + startSpinner() + await v2.delete(`${endpoint}/${encodeURIComponent(userId)}`) + showSnackBar({ msg: `User '${userId}' deleted.` }) + return null + } catch (err: any) { + const msg = errorMessage(err, `Failed to delete user '${userId}'.`) + showSnackBar({ msg, error: true }) + return msg + } finally { + stopSpinner() + } +} + +export { + createManagedUser, + deleteManagedUser, + getAvailableUserRoles, + getManagedUsers, + renameManagedUser, + setManagedUserPassword, + updateManagedUser +} diff --git a/ui/src/stores/userAdminStore.ts b/ui/src/stores/userAdminStore.ts new file mode 100644 index 000000000000..6fd3695266df --- /dev/null +++ b/ui/src/stores/userAdminStore.ts @@ -0,0 +1,95 @@ +/// +/// Licensed to The OpenNMS Group, Inc (TOG) under one or more +/// contributor license agreements. See the LICENSE.md file +/// distributed with this work for additional information +/// regarding copyright ownership. +/// +/// TOG licenses this file to You under the GNU Affero General +/// Public License Version 3 (the "License") or (at your option) +/// any later version. You may not use this file except in +/// compliance with the License. You may obtain a copy of the +/// License at: +/// +/// https://www.gnu.org/licenses/agpl-3.0.txt +/// +/// Unless required by applicable law or agreed to in writing, +/// software distributed under the License is distributed on an +/// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +/// either express or implied. See the License for the specific +/// language governing permissions and limitations under the +/// License. +/// + +import API from '@/services' +import { ManagedUser, ManagedUserCreate } from '@/types/userAdmin' +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const useUserAdminStore = defineStore('userAdminStore', () => { + const users = ref([] as ManagedUser[]) + const availableRoles = ref([] as string[]) + + const getUsers = async () => { + const result = await API.getManagedUsers() + if (result !== null) { + users.value = result + } + } + + const getAvailableRoles = async () => { + availableRoles.value = await API.getAvailableUserRoles() + } + + const createUser = async (user: ManagedUserCreate) => { + const error = await API.createManagedUser(user) + if (error === null) { + await getUsers() + } + return error + } + + const updateUser = async (user: ManagedUser) => { + const error = await API.updateManagedUser(user) + if (error === null) { + await getUsers() + } + return error + } + + const setPassword = async (userId: string, password: string) => { + return await API.setManagedUserPassword(userId, password) + } + + const renameUser = async (userId: string, newUserId: string) => { + const error = await API.renameManagedUser(userId, newUserId) + if (error === null) { + await getUsers() + } + return error + } + + const deleteUser = async (userId: string) => { + const error = await API.deleteManagedUser(userId) + if (error === null) { + await getUsers() + } + return error + } + + const populate = async () => { + await Promise.all([getUsers(), getAvailableRoles()]) + } + + return { + users, + availableRoles, + getUsers, + getAvailableRoles, + createUser, + updateUser, + setPassword, + renameUser, + deleteUser, + populate + } +}) diff --git a/ui/src/types/userAdmin.ts b/ui/src/types/userAdmin.ts new file mode 100644 index 000000000000..1abb6d6bfdc6 --- /dev/null +++ b/ui/src/types/userAdmin.ts @@ -0,0 +1,47 @@ +/// +/// Licensed to The OpenNMS Group, Inc (TOG) under one or more +/// contributor license agreements. See the LICENSE.md file +/// distributed with this work for additional information +/// regarding copyright ownership. +/// +/// TOG licenses this file to You under the GNU Affero General +/// Public License Version 3 (the "License") or (at your option) +/// any later version. You may not use this file except in +/// compliance with the License. You may obtain a copy of the +/// License at: +/// +/// https://www.gnu.org/licenses/agpl-3.0.txt +/// +/// Unless required by applicable law or agreed to in writing, +/// software distributed under the License is distributed on an +/// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +/// either express or implied. See the License for the specific +/// language governing permissions and limitations under the +/// License. +/// + +// Wire shapes of the v2 user management API (/api/v2/users). Field names +// follow users.xml; the password hash is never part of any response, and +// contact types the API does not expose (XMPP among them) are preserved +// server-side on update. + +export interface ManagedUser { + 'user-id': string + 'full-name'?: string | null + 'user-comments'?: string | null + email?: string | null + 'pager-email'?: string | null + 'tui-pin'?: string | null + 'time-zone-id'?: string | null + 'duty-schedule'?: string[] + role?: string[] + 'read-only'?: boolean +} + +export interface ManagedUserCreate extends ManagedUser { + password: string +} + +// System accounts the server refuses to delete or rename; mirrored here so +// the UI can disable the controls with an explanation instead of a 400. +export const PROTECTED_USER_IDS = ['admin', 'rtc'] diff --git a/ui/tests/components/AdminDialogs/UserEditorDialog.test.ts b/ui/tests/components/AdminDialogs/UserEditorDialog.test.ts new file mode 100644 index 000000000000..19ebe96e5cdb --- /dev/null +++ b/ui/tests/components/AdminDialogs/UserEditorDialog.test.ts @@ -0,0 +1,94 @@ +import UserEditorDialog from '@/components/ManageUsers/UserEditorDialog.vue' +import { useUserAdminStore } from '@/stores/userAdminStore' +import { flushPromises, mount, VueWrapper } from '@vue/test-utils' +import PrimeVue from 'primevue/config' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/stores/userAdminStore') + +const DialogStub = { + name: 'Dialog', + props: ['visible', 'header', 'modal'], + template: '
' +} + +describe('UserEditorDialog.vue', () => { + let wrapper: VueWrapper + let store: any + + const mountDialog = async (user: any = null) => { + wrapper = mount(UserEditorDialog, { + props: { visible: false, user }, + global: { + plugins: [PrimeVue], + stubs: { Dialog: DialogStub } + } + }) + await wrapper.setProps({ visible: true }) + await flushPromises() + } + + const setPassword = async (value: string) => { + await wrapper.find('[data-test="password-input"] input').setValue(value) + } + + beforeEach(() => { + vi.clearAllMocks() + store = { + availableRoles: ['ROLE_USER', 'ROLE_ADMIN'], + createUser: vi.fn().mockResolvedValue(null), + updateUser: vi.fn().mockResolvedValue(null) + } + vi.mocked(useUserAdminStore).mockReturnValue(store) + }) + + it('flags a user id with whitespace or reserved characters and disables saving', async () => { + await mountDialog() + await wrapper.find('[data-test="user-id-input"]').setValue('jose anes') + await setPassword('secret') + + expect(wrapper.find('[data-test="user-id-error"]').text()).toContain('must not contain') + expect(wrapper.find('[data-test="save-button"]').attributes('disabled')).toBeDefined() + }) + + it('flags an email without a domain part', async () => { + await mountDialog() + await wrapper.find('[data-test="user-id-input"]').setValue('jose') + await setPassword('secret') + await wrapper.find('[data-test="email-input"]').setValue('not-an-email') + + expect(wrapper.find('[data-test="email-error"]').exists()).toBe(true) + expect(wrapper.find('[data-test="save-button"]').attributes('disabled')).toBeDefined() + }) + + it('requires a password before a new user can be saved', async () => { + await mountDialog() + await wrapper.find('[data-test="user-id-input"]').setValue('jose') + + expect(wrapper.find('[data-test="save-button"]').attributes('disabled')).toBeDefined() + }) + + it('creates a valid user and closes', async () => { + await mountDialog() + await wrapper.find('[data-test="user-id-input"]').setValue('jose') + await setPassword('secret') + await wrapper.find('[data-test="email-input"]').setValue('jose@example.org') + await wrapper.find('[data-test="save-button"]').trigger('click') + await flushPromises() + + expect(store.createUser).toHaveBeenCalledWith(expect.objectContaining({ 'user-id': 'jose', password: 'secret' })) + expect(wrapper.emitted('update:visible')?.at(-1)).toEqual([false]) + }) + + it('shows a server rejection inside the dialog and stays open', async () => { + store.createUser.mockResolvedValue('User jose already exists.') + await mountDialog() + await wrapper.find('[data-test="user-id-input"]').setValue('jose') + await setPassword('secret') + await wrapper.find('[data-test="save-button"]').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-test="dialog-error"]').text()).toContain('already exists') + expect(wrapper.emitted('update:visible') ?? []).toEqual([]) + }) +}) diff --git a/ui/tests/lib/adminValidation.test.ts b/ui/tests/lib/adminValidation.test.ts new file mode 100644 index 000000000000..04062da50be5 --- /dev/null +++ b/ui/tests/lib/adminValidation.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { + isPathAddressable, + validateAdminComments, + validateAdminName, + validateEmailShape +} from '@/lib/adminValidation' + +describe('validateAdminName', () => { + it('accepts ordinary names and empty values', () => { + expect(validateAdminName('NOC-Duty_1', 'group name')).toBeNull() + expect(validateAdminName('', 'group name')).toBeNull() + expect(validateAdminName(' ', 'group name')).toBeNull() + }) + + it('rejects whitespace, markup and URL-hostile characters', () => { + for (const bad of ['Test Group', 'a { + expect(validateAdminName('.', 'user-id')).toContain('dot segment') + expect(validateAdminName('..', 'user-id')).toContain('dot segment') + }) + + it('names the field in the message', () => { + expect(validateAdminName('a b', 'role name')).toContain('role name') + }) +}) + +describe('validateAdminComments', () => { + it('accepts plain text and empty values', () => { + expect(validateAdminComments('The administrators, on shift 24/7.')).toBeNull() + expect(validateAdminComments('')).toBeNull() + }) + + it('rejects markup characters', () => { + for (const bad of ['x', 'a & b', 'quote "x"', "it's", 'tick `x`']) { + expect(validateAdminComments(bad), bad).not.toBeNull() + } + }) +}) + +describe('validateEmailShape', () => { + it('accepts empty values and common deliverable forms', () => { + expect(validateEmailShape('', 'email')).toBeNull() + expect(validateEmailShape('noc@example.org', 'email')).toBeNull() + expect(validateEmailShape('Bill Smith ', 'email')).toBeNull() + expect(validateEmailShape('a@example.com, b@example.com', 'email')).toBeNull() + }) + + it('rejects values without a local@domain part', () => { + expect(validateEmailShape('not-an-email', 'email')).toContain('email') + expect(validateEmailShape('a@', 'email')).not.toBeNull() + expect(validateEmailShape('a@example.com,,b@example.com', 'pager email')).toContain('pager email') + }) +}) + +describe('isPathAddressable', () => { + it('allows ordinary names', () => { + expect(isPathAddressable('NOC-Duty')).toBe(true) + expect(isPathAddressable('Some Group')).toBe(true) + }) + + it('flags names the security filter cannot address as path segments', () => { + expect(isPathAddressable('NOC/Primary')).toBe(false) + expect(isPathAddressable('a\\b')).toBe(false) + expect(isPathAddressable('a%b')).toBe(false) + }) +}) diff --git a/ui/tests/stores/userAdminStore.test.ts b/ui/tests/stores/userAdminStore.test.ts new file mode 100644 index 000000000000..710053f6fd7b --- /dev/null +++ b/ui/tests/stores/userAdminStore.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useUserAdminStore } from '@/stores/userAdminStore' +import API from '@/services' +import { ManagedUser } from '@/types/userAdmin' + +vi.mock('@/services', () => ({ + default: { + getManagedUsers: vi.fn(), + getAvailableUserRoles: vi.fn(), + createManagedUser: vi.fn(), + updateManagedUser: vi.fn(), + setManagedUserPassword: vi.fn(), + renameManagedUser: vi.fn(), + deleteManagedUser: vi.fn() + } +})) + +describe('useUserAdminStore', () => { + let store: ReturnType + + const mockUsers: ManagedUser[] = [ + { 'user-id': 'admin', 'full-name': 'Administrator', role: ['ROLE_ADMIN'] }, + { 'user-id': 'noc', 'full-name': 'NOC Operator', email: 'noc@example.com', role: ['ROLE_USER'] } + ] + + beforeEach(() => { + setActivePinia(createPinia()) + store = useUserAdminStore() + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should start empty', () => { + expect(store.users).toEqual([]) + expect(store.availableRoles).toEqual([]) + }) + + it('populate should load users and roles', async () => { + vi.mocked(API.getManagedUsers).mockResolvedValue(mockUsers) + vi.mocked(API.getAvailableUserRoles).mockResolvedValue(['ROLE_ADMIN', 'ROLE_USER']) + + await store.populate() + + expect(store.users).toEqual(mockUsers) + expect(store.availableRoles).toEqual(['ROLE_ADMIN', 'ROLE_USER']) + }) + + it('createUser should refresh on success', async () => { + vi.mocked(API.createManagedUser).mockResolvedValue(null) + vi.mocked(API.getManagedUsers).mockResolvedValue(mockUsers) + + const ok = await store.createUser({ 'user-id': 'noc', password: 'secret' }) + + expect(ok).toBe(null) + expect(API.getManagedUsers).toHaveBeenCalledTimes(1) + }) + + it('createUser should not refresh on failure', async () => { + vi.mocked(API.createManagedUser).mockResolvedValue('it failed') + + const ok = await store.createUser({ 'user-id': 'noc', password: 'secret' }) + + expect(ok).toBe('it failed') + expect(API.getManagedUsers).not.toHaveBeenCalled() + }) + + it('updateUser should refresh on success', async () => { + vi.mocked(API.updateManagedUser).mockResolvedValue(null) + vi.mocked(API.getManagedUsers).mockResolvedValue(mockUsers) + + await store.updateUser(mockUsers[1]) + + expect(API.updateManagedUser).toHaveBeenCalledWith(mockUsers[1]) + expect(API.getManagedUsers).toHaveBeenCalledTimes(1) + }) + + it('renameUser should pass old and new ids and refresh', async () => { + vi.mocked(API.renameManagedUser).mockResolvedValue(null) + vi.mocked(API.getManagedUsers).mockResolvedValue(mockUsers) + + await store.renameUser('noc', 'noc2') + + expect(API.renameManagedUser).toHaveBeenCalledWith('noc', 'noc2') + expect(API.getManagedUsers).toHaveBeenCalledTimes(1) + }) + + it('deleteUser should refresh on success', async () => { + vi.mocked(API.deleteManagedUser).mockResolvedValue(null) + vi.mocked(API.getManagedUsers).mockResolvedValue([mockUsers[0]]) + + await store.deleteUser('noc') + + expect(store.users).toEqual([mockUsers[0]]) + }) + + it('a failed refresh should keep the previous user list', async () => { + vi.mocked(API.getManagedUsers).mockResolvedValue(mockUsers) + await store.getUsers() + expect(store.users).toEqual(mockUsers) + + vi.mocked(API.getManagedUsers).mockResolvedValue(null) + await store.getUsers() + expect(store.users).toEqual(mockUsers) + }) + + it('setPassword should not trigger a reload', async () => { + vi.mocked(API.setManagedUserPassword).mockResolvedValue(null) + + const ok = await store.setPassword('noc', 'newpw') + + expect(ok).toBe(null) + expect(API.setManagedUserPassword).toHaveBeenCalledWith('noc', 'newpw') + expect(API.getManagedUsers).not.toHaveBeenCalled() + }) +})