diff --git a/opennms-config/src/main/java/org/opennms/netmgt/config/GroupManager.java b/opennms-config/src/main/java/org/opennms/netmgt/config/GroupManager.java index c8dfb98a75f6..3714bc1b0c4f 100644 --- a/opennms-config/src/main/java/org/opennms/netmgt/config/GroupManager.java +++ b/opennms-config/src/main/java/org/opennms/netmgt/config/GroupManager.java @@ -475,15 +475,25 @@ public void deleteRole(String name) throws Exception { */ public synchronized void renameGroup(String oldName, String newName) throws Exception { if (oldName != null && !oldName.equals("")) { - if (m_groups.containsKey(oldName)) { - Group grp = m_groups.remove(oldName); - grp.setName(newName); - m_groups.put(newName, grp); - } else { + if (!m_groups.containsKey(oldName)) { throw new Exception("GroupFactory.renameGroup: Group doesn't exist: " + oldName); } - // Save into groups.xml - saveGroups(); + Group grp = m_groups.remove(oldName); + grp.setName(newName); + m_groups.put(newName, grp); + try { + // Save into groups.xml + saveGroups(); + } catch (final Exception e) { + // The save did not persist, so undo the in-memory rename: the map + // must keep reflecting groups.xml. Callers key referential-integrity + // decisions (and their rollback) on hasGroup(), which must not report + // a rename the file never received. + m_groups.remove(newName); + grp.setName(oldName); + m_groups.put(oldName, grp); + throw e; + } } } diff --git a/opennms-config/src/test/java/org/opennms/netmgt/config/GroupManagerRenameTest.java b/opennms-config/src/test/java/org/opennms/netmgt/config/GroupManagerRenameTest.java new file mode 100644 index 000000000000..e466e642c4f8 --- /dev/null +++ b/opennms-config/src/test/java/org/opennms/netmgt/config/GroupManagerRenameTest.java @@ -0,0 +1,100 @@ +/******************************************************************************* + * This file is part of OpenNMS(R). + * + * Copyright (C) 2026 The OpenNMS Group, Inc. + * OpenNMS(R) is Copyright (C) 1999-2026 The OpenNMS Group, Inc. + * + * OpenNMS(R) is a registered trademark of The OpenNMS Group, Inc. + * + * OpenNMS(R) is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, + * or (at your option) any later version. + * + * OpenNMS(R) is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with OpenNMS(R). If not, see: + * http://www.gnu.org/licenses/ + * + * For more information contact: + * OpenNMS(R) Licensing + * http://www.opennms.org/ + * http://www.opennms.com/ + *******************************************************************************/ + +package org.opennms.netmgt.config; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.junit.Test; + +public class GroupManagerRenameTest { + + private static final String GROUPS_XML = + "\n" + + "
1.0nowtest
\n" + + " \n" + + " oldgroupcadmin\n" + + " \n" + + "
"; + + /** A GroupManager with no file backing whose save can be flipped to fail. */ + private static final class TestGroupManager extends GroupManager { + private boolean m_failSave = false; + + TestGroupManager(final String xml) throws IOException { + parseXml(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } + + void failNextSave() { + m_failSave = true; + } + + @Override + public void update() { + // nothing on disk to reconcile against + } + + @Override + protected void saveXml(final String data) throws IOException { + if (m_failSave) { + throw new IOException("simulated save failure"); + } + } + } + + @Test + public void renameRollsBackWhenSaveFails() throws Exception { + final TestGroupManager gm = new TestGroupManager(GROUPS_XML); + gm.failNextSave(); + try { + gm.renameGroup("oldgroup", "newgroup"); + fail("the save failure should have propagated"); + } catch (final IOException expected) { + // the in-memory map must keep reflecting groups.xml, not a phantom rename + } + assertTrue("the old group must survive a failed save", gm.hasGroup("oldgroup")); + assertFalse("a failed save must not leave a phantom renamed group", gm.hasGroup("newgroup")); + assertEquals("oldgroup", gm.getGroup("oldgroup").getName()); + } + + @Test + public void renameCommitsWhenSaveSucceeds() throws Exception { + final TestGroupManager gm = new TestGroupManager(GROUPS_XML); + gm.renameGroup("oldgroup", "newgroup"); + assertTrue(gm.hasGroup("newgroup")); + assertFalse(gm.hasGroup("oldgroup")); + assertEquals("newgroup", gm.getGroup("newgroup").getName()); + } +} diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/GroupsRestService.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/GroupsRestService.java new file mode 100644 index 000000000000..b9901950c78a --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/GroupsRestService.java @@ -0,0 +1,403 @@ +/* + * 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.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.Status; +import javax.ws.rs.core.SecurityContext; + +import org.apache.commons.lang3.StringUtils; +import org.opennms.netmgt.config.GroupManager; +import org.opennms.netmgt.config.UserManager; +import org.opennms.netmgt.config.groups.Group; +import org.opennms.netmgt.config.groups.Role; +import org.opennms.web.api.Authentication; +import org.opennms.web.svclayer.api.GroupService; +import org.opennms.web.rest.v2.api.GroupsRestApi; +import org.opennms.web.rest.v2.model.GroupDto; +import org.opennms.web.rest.v2.model.GroupRenameRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * Versioned group management on top of {@link GroupManager}: groups.xml + * remains the system of record and hand-editing keeps working. Unlike the + * legacy JSPs (which only hid the buttons), the Admin-group protections are + * enforced here, server-side, and referential integrity with on-call roles is + * handled deliberately: renames follow the roles' membership-group, deletes + * are rejected while roles still reference the group. + * + * Mutations validate the full request up front and then apply it to a + * detached copy of the stored group, so a rejected request can never leave + * partial changes in the manager's shared in-memory state. + */ +@Component("groupsRestServiceV2") +public class GroupsRestService implements GroupsRestApi { + + // Check-then-act sequences synchronize on GroupFactory.class: user + // mutations cascade into GroupManager (deleteUser/renameUser walk every + // group), so a service-private lock cannot serialize the two v2 services + // against each other. + + private static final Logger LOG = LoggerFactory.getLogger(GroupsRestService.class); + + /** The default group; the on-call role machinery references it. */ + private static final Set PROTECTED_GROUPS = Set.of("Admin"); + + /** Markup per the legacy controller, plus URL-path-segment safety. */ + private static final Pattern INVALID_NAME = Pattern.compile("[&<>\"`':/\\\\%?#\\s]"); + + private static final Pattern INVALID_COMMENTS = Pattern.compile("[&<>\"`']"); + + /** Same grammar as the users API. */ + 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 GroupManager m_groupManager; + + @Autowired + private UserManager m_userManager; + + /** Handles the DB-side category authorizations on delete/rename. */ + @Autowired + private GroupService m_groupService; + + @Override + public Response listGroups(final SecurityContext securityContext) { + assertAdmin(securityContext); + try { + synchronized (org.opennms.netmgt.config.GroupFactory.class) { + final List groups = new ArrayList<>(); + for (final Group group : m_groupManager.getGroups().values()) { + groups.add(toDto(group)); + } + groups.sort(Comparator.comparing(GroupDto::getName, String.CASE_INSENSITIVE_ORDER)); + return Response.ok(groups).build(); + } + } catch (final Exception e) { + return serverError("Can't read groups: %s", e); + } + } + + @Override + public Response getGroup(final SecurityContext securityContext, final String name) { + assertAdmin(securityContext); + try { + synchronized (org.opennms.netmgt.config.GroupFactory.class) { + final Group group = m_groupManager.getGroup(name); + if (group == null) { + return Response.status(Status.NOT_FOUND).entity("Group " + name + " was not found.").build(); + } + return Response.ok(toDto(group)).build(); + } + } catch (final Exception e) { + return serverError("Can't read group: %s", e); + } + } + + @Override + public Response createGroup(final SecurityContext securityContext, final GroupDto dto) { + assertAdmin(securityContext); + if (dto == null || StringUtils.isBlank(dto.getName())) { + return Response.status(Status.BAD_REQUEST).entity("A group name is required.").build(); + } + final String name = dto.getName().trim(); + final String nameProblem = validateName(name); + if (nameProblem != null) { + return Response.status(Status.BAD_REQUEST).entity(nameProblem).build(); + } + try { + validateDtoFields(dto, null); + synchronized (org.opennms.netmgt.config.GroupFactory.class) { + if (m_groupManager.hasGroup(name)) { + return Response.status(Status.BAD_REQUEST).entity("Group " + name + " already exists.").build(); + } + final Group group = new Group(); + group.setName(name); + applyDto(group, dto); + m_groupManager.saveGroup(name, group); + } + LOG.info("Group {} created by {}", name, principal(securityContext)); + return Response.status(Status.CREATED).build(); + } catch (final Exception e) { + return serverError("Can't create group: %s", e); + } + } + + @Override + public Response updateGroup(final SecurityContext securityContext, final String name, final GroupDto dto) { + assertAdmin(securityContext); + if (dto == null) { + return Response.status(Status.BAD_REQUEST).entity("A group body is required.").build(); + } + if (dto.getName() != null && !name.equals(dto.getName())) { + return Response.status(Status.BAD_REQUEST) + .entity("The name in the body does not match the request path; use the rename endpoint to change names.").build(); + } + try { + synchronized (org.opennms.netmgt.config.GroupFactory.class) { + final Group existing = m_groupManager.getGroup(name); + if (existing == null) { + return Response.status(Status.NOT_FOUND).entity("Group " + name + " was not found.").build(); + } + validateDtoFields(dto, existing); + final Group updated = copyOf(existing); + applyDto(updated, dto); + m_groupManager.saveGroup(name, updated); + } + return Response.noContent().build(); + } catch (final Exception e) { + return serverError("Can't update group: %s", e); + } + } + + @Override + public Response renameGroup(final SecurityContext securityContext, final String name, final GroupRenameRequest request) { + assertAdmin(securityContext); + if (request == null || StringUtils.isBlank(request.getNewName())) { + return Response.status(Status.BAD_REQUEST).entity("A newName is required.").build(); + } + if (PROTECTED_GROUPS.contains(name)) { + return Response.status(Status.BAD_REQUEST).entity("The system group " + name + " cannot be renamed.").build(); + } + final String newName = request.getNewName().trim(); + final String nameProblem = validateName(newName); + if (nameProblem != null) { + return Response.status(Status.BAD_REQUEST).entity(nameProblem).build(); + } + try { + synchronized (org.opennms.netmgt.config.GroupFactory.class) { + if (!m_groupManager.hasGroup(name)) { + return Response.status(Status.NOT_FOUND).entity("Group " + name + " was not found.").build(); + } + if (m_groupManager.hasGroup(newName)) { + return Response.status(Status.BAD_REQUEST).entity("Group " + newName + " already exists.").build(); + } + // Pre-point the on-call roles at the new name in memory, then + // let the rename's single save persist groups AND roles + // together (GroupManager.renameGroup ends in saveGroups, which + // serializes both). GroupService.renameGroup also migrates the + // DB category authorizations, as the legacy page did. + final List repointedRoles = new ArrayList<>(); + for (final Role role : m_groupManager.getRoles()) { + if (name.equals(role.getMembershipGroup())) { + role.setMembershipGroup(newName); + repointedRoles.add(role); + } + } + try { + m_groupService.renameGroup(name, newName); + } catch (final Exception e) { + // only roll the roles back if the rename did NOT persist + // (GroupService renames the file first, then migrates the + // DB category authorizations); re-pointing after a + // persisted rename would diverge memory from groups.xml + if (m_groupManager.hasGroup(newName) && !m_groupManager.hasGroup(name)) { + throw new IllegalStateException("The group was renamed, but migrating its category" + + " authorizations failed; review the group's authorized categories. (" + + e.getMessage() + ")", e); + } + for (final Role role : repointedRoles) { + role.setMembershipGroup(name); + } + throw e; + } + } + LOG.info("Group {} renamed to {} by {}", name, newName, principal(securityContext)); + return Response.noContent().build(); + } catch (final Exception e) { + return serverError("Can't rename group: %s", e); + } + } + + @Override + public Response deleteGroup(final SecurityContext securityContext, final String name) { + assertAdmin(securityContext); + if (PROTECTED_GROUPS.contains(name)) { + return Response.status(Status.BAD_REQUEST).entity("The system group " + name + " cannot be deleted.").build(); + } + try { + synchronized (org.opennms.netmgt.config.GroupFactory.class) { + if (!m_groupManager.hasGroup(name)) { + return Response.status(Status.NOT_FOUND).entity("Group " + name + " was not found.").build(); + } + final List referencingRoles = m_groupManager.getRoles().stream() + .filter(role -> name.equals(role.getMembershipGroup())) + .map(Role::getName) + .sorted() + .collect(Collectors.toList()); + if (!referencingRoles.isEmpty()) { + return Response.status(Status.BAD_REQUEST) + .entity("Group " + name + " is the membership group of on-call role(s) " + String.join(", ", referencingRoles) + + "; delete or reassign those roles first.").build(); + } + // GroupService.deleteGroup also clears the DB category + // authorizations; without that, a future group reusing the + // name would silently inherit the old authorizations + m_groupService.deleteGroup(name); + } + LOG.info("Group {} deleted by {}", name, principal(securityContext)); + return Response.noContent().build(); + } catch (final Exception e) { + return serverError("Can't delete group: %s", e); + } + } + + private static GroupDto toDto(final Group group) { + final GroupDto dto = new GroupDto(); + dto.setName(group.getName()); + dto.setComments(group.getComments().orElse(null)); + dto.setUsers(new ArrayList<>(group.getUsers())); + dto.setDutySchedules(new ArrayList<>(group.getDutySchedules())); + return dto; + } + + /** Detached copy so mutations never touch the manager's live object. */ + private static Group copyOf(final Group group) { + final Group copy = new Group(); + copy.setName(group.getName()); + group.getDefaultMap().ifPresent(copy::setDefaultMap); + group.getComments().ifPresent(copy::setComments); + copy.setUsers(new ArrayList<>(group.getUsers())); + copy.setDutySchedules(new ArrayList<>(group.getDutySchedules())); + return copy; + } + + /** + * Validates every field of the request BEFORE anything is applied, so a + * rejected request cannot leave partial state anywhere. + */ + private void validateDtoFields(final GroupDto dto, final Group existing) throws Exception { + if (dto.getComments() != null && INVALID_COMMENTS.matcher(dto.getComments()).find() + && (existing == null || !dto.getComments().equals(existing.getComments().orElse(null)))) { + throw new IllegalArgumentException("The comments must not contain any HTML markup."); + } + if (dto.getUsers() != null) { + // members already stored on the group are grandfathered (a + // hand-edited file may reference a user that no longer exists); + // only members new to this request must resolve to a real user + final Set preExistingMembers = existing == null + ? Set.of() : new LinkedHashSet<>(existing.getUsers()); + final Set seen = new LinkedHashSet<>(); + for (final String user : dto.getUsers()) { + if (StringUtils.isBlank(user)) { + throw new IllegalArgumentException("Group members must not be blank."); + } + if (!seen.add(user)) { + throw new IllegalArgumentException("Duplicate group member: " + user); + } + if (!preExistingMembers.contains(user) && !m_userManager.hasUser(user)) { + throw new IllegalArgumentException("Unknown user: " + user); + } + } + } + if (dto.getDutySchedules() != null) { + // strings already stored on the record are preserved as-is so + // hand-edited files never make a group uneditable; only entries + // new to this request must pass validation + final Set preExisting = existing == null + ? Set.of() : new LinkedHashSet<>(existing.getDutySchedules()); + for (final String schedule : dto.getDutySchedules()) { + if (!preExisting.contains(schedule)) { + validateDutySchedule(schedule); + } + } + } + } + + /** + * Applies the pre-validated DTO. default-map is not exposed by the API and + * survives untouched; list fields left out of the request body arrive as + * null and are preserved. The user list order is kept exactly as sent — + * it drives the notification escalation order. + */ + private static void applyDto(final Group group, final GroupDto dto) { + if (dto.getComments() != null) { + group.setComments(StringUtils.trimToNull(dto.getComments())); + } + if (dto.getUsers() != null) { + group.setUsers(new ArrayList<>(dto.getUsers())); + } + if (dto.getDutySchedules() != null) { + group.setDutySchedules(new ArrayList<>(dto.getDutySchedules())); + } + } + + /** Returns a problem description, or null when the group name is acceptable. */ + private static String validateName(final String name) { + if (INVALID_NAME.matcher(name).find()) { + return "The group name must not contain markup, whitespace, or the characters : / \\ % ? #"; + } + if (".".equals(name) || "..".equals(name)) { + return "The group name must not be a dot segment."; + } + return null; + } + + 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 void assertAdmin(final SecurityContext securityContext) { + if (securityContext == null || !securityContext.isUserInRole(Authentication.ROLE_ADMIN)) { + throw new javax.ws.rs.WebApplicationException( + Response.status(Status.FORBIDDEN).entity("Group 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(); + } +} diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/api/GroupsRestApi.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/api/GroupsRestApi.java new file mode 100644 index 000000000000..8a38fe00ffff --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/api/GroupsRestApi.java @@ -0,0 +1,83 @@ +/* + * 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.GroupDto; +import org.opennms.web.rest.v2.model.GroupRenameRequest; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Versioned group management API backed by groups.xml. Admin-only + * (enforced by Spring Security and in-code). + */ +@Path("groups") +@Tag(name = "Groups", description = "Group Management API") +public interface GroupsRestApi { + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "List all groups", operationId = "listGroups") + Response listGroups(@Context SecurityContext securityContext); + + @GET + @Path("{name}") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Get one group", operationId = "getGroup") + Response getGroup(@Context SecurityContext securityContext, @PathParam("name") String name); + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Operation(summary = "Create a group", operationId = "createGroup") + Response createGroup(@Context SecurityContext securityContext, GroupDto group); + + @PUT + @Path("{name}") + @Consumes(MediaType.APPLICATION_JSON) + @Operation(summary = "Update a group (fields not carried by the API are preserved; the user list order drives notification escalation)", operationId = "updateGroup") + Response updateGroup(@Context SecurityContext securityContext, @PathParam("name") String name, GroupDto group); + + @POST + @Path("{name}/rename") + @Consumes(MediaType.APPLICATION_JSON) + @Operation(summary = "Rename a group (on-call roles referencing it follow the rename)", operationId = "renameGroup") + Response renameGroup(@Context SecurityContext securityContext, @PathParam("name") String name, GroupRenameRequest request); + + @DELETE + @Path("{name}") + @Operation(summary = "Delete a group (rejected while on-call roles reference it; the Admin group is protected)", operationId = "deleteGroup") + Response deleteGroup(@Context SecurityContext securityContext, @PathParam("name") String name); +} diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/GroupDto.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/GroupDto.java new file mode 100644 index 000000000000..0fe3f470a1de --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/GroupDto.java @@ -0,0 +1,75 @@ +/* + * 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; + + +/** + * A group as exposed by the v2 user management API. Field names mirror + * groups.xml where they overlap. The user list is ordered — the order drives + * notification escalation. Fields the API does not expose (default-map) are + * preserved server-side on update; list fields default to null (not empty) so + * a request body that omits them means "preserve". + */ +public class GroupDto { + + private String name; + + private String comments; + + private List users; + + private List dutySchedules; + + public String getName() { + return name; + } + + public void setName(final String name) { + this.name = name; + } + + public String getComments() { + return comments; + } + + public void setComments(final String comments) { + this.comments = comments; + } + + public List getUsers() { + return users; + } + + public void setUsers(final List users) { + this.users = users; + } + + public List getDutySchedules() { + return dutySchedules; + } + + public void setDutySchedules(final List dutySchedules) { + this.dutySchedules = dutySchedules; + } +} diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/GroupRenameRequest.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/GroupRenameRequest.java new file mode 100644 index 000000000000..abeb342167d9 --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/GroupRenameRequest.java @@ -0,0 +1,36 @@ +/* + * 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; + + +public class GroupRenameRequest { + + private String newName; + + public String getNewName() { + return newName; + } + + public void setNewName(final String newName) { + this.newName = newName; + } +} diff --git a/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template-default.json b/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template-default.json index d5a81390467e..cfa99491d2fd 100644 --- a/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template-default.json +++ b/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template-default.json @@ -411,7 +411,7 @@ { "id": "manageGroups", "name": "Manage Groups", - "url": "admin/userGroupView/groups/list.htm", + "url": "ui/index.html#/admin/groups", "locationMatch": "", "roles": null }, 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..cfa99491d2fd 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 @@ -411,7 +411,7 @@ { "id": "manageGroups", "name": "Manage Groups", - "url": "admin/userGroupView/groups/list.htm", + "url": "ui/index.html#/admin/groups", "locationMatch": "", "roles": null }, diff --git a/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/GroupsRestServiceIT.java b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/GroupsRestServiceIT.java new file mode 100644 index 000000000000..bb04f78f930d --- /dev/null +++ b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/GroupsRestServiceIT.java @@ -0,0 +1,383 @@ +/* + * 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.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.GroupManager; +import org.opennms.netmgt.config.UserManager; +import org.opennms.netmgt.config.groups.Group; +import org.opennms.netmgt.config.groups.Role; +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 groups.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 GroupsRestServiceIT extends AbstractSpringJerseyRestTestCase { + + @Autowired + private GroupManager m_groupManager; + + @Autowired + private UserManager m_userManager; + + public GroupsRestServiceIT() { + super(CXF_REST_V2_CONTEXT_PATH); + } + + @Override + protected void beforeServletStart() { + MockLogAppender.setupLogging(); + } + + private void ensureUser(final String userId) throws Exception { + if (!m_userManager.hasUser(userId)) { + final User user = new User(); + user.setUserId(userId); + user.setPassword(m_userManager.encryptedPassword("pw", true), Boolean.TRUE); + m_userManager.saveUser(userId, user); + } + } + + @Test + public void testListAndGet() throws Exception { + final JSONArray groups = new JSONArray(getJson("/groups", 200)); + assertTrue(groups.length() >= 1); + assertEquals("Admin", groups.getJSONObject(0).getString("name")); + + final JSONObject admin = new JSONObject(getJson("/groups/Admin", 200)); + assertEquals("admin", admin.getJSONArray("users").getString(0)); + + sendRequest(GET, "/groups/idontexist", 404); + } + + @Test + public void testCreateLifecycle() throws Exception { + ensureUser("alpha"); + ensureUser("beta"); + final String body = "{\"name\":\"junitgroup\",\"comments\":\"a junit group\"," + + "\"users\":[\"beta\",\"alpha\"],\"dutySchedules\":[\"MoWeFr800-1700\"]}"; + sendData(POST, MediaType.APPLICATION_JSON, "/groups", body, 201); + + final JSONObject created = new JSONObject(getJson("/groups/junitgroup", 200)); + assertEquals("a junit group", created.getString("comments")); + // the member order drives notification escalation and must round-trip + assertEquals("beta", created.getJSONArray("users").getString(0)); + assertEquals("alpha", created.getJSONArray("users").getString(1)); + assertEquals("MoWeFr800-1700", created.getJSONArray("dutySchedules").getString(0)); + + // creating the same group again must be rejected + sendData(POST, MediaType.APPLICATION_JSON, "/groups", body, 400); + + sendRequest(DELETE, "/groups/junitgroup", 204); + sendRequest(GET, "/groups/junitgroup", 404); + } + + @Test + public void testCreateValidation() throws Exception { + // markup / path-segment characters in the name + sendData(POST, MediaType.APPLICATION_JSON, "/groups", "{\"name\":\"bad\"}", 400); + sendData(POST, MediaType.APPLICATION_JSON, "/groups", "{\"name\":\"team/lead\"}", 400); + sendData(POST, MediaType.APPLICATION_JSON, "/groups", "{\"name\":\"with space\"}", 400); + // markup in the comments + sendData(POST, MediaType.APPLICATION_JSON, "/groups", "{\"name\":\"badc\",\"comments\":\"\"}", 400); + sendRequest(GET, "/groups/nlgroup", 404); + } + + @Test + public void testHandEditedMarkupCommentStaysEditable() throws Exception { + // "Bob's R&D team" is legal free text in a hand-edited groups.xml; + // the group must stay editable while the comment is unchanged + final Group group = new Group(); + group.setName("legacycomment"); + group.setComments("Bob's R&D team"); + m_groupManager.saveGroup("legacycomment", group); + + sendData(PUT, MediaType.APPLICATION_JSON, "/groups/legacycomment", + "{\"name\":\"legacycomment\",\"comments\":\"Bob's R&D team\",\"users\":[\"admin\"]}", 204); + final JSONObject after = new JSONObject(getJson("/groups/legacycomment", 200)); + assertEquals("Bob's R&D team", after.getString("comments")); + assertEquals("admin", after.getJSONArray("users").getString(0)); + + // but CHANGING the comment to new markup is still rejected + sendData(PUT, MediaType.APPLICATION_JSON, "/groups/legacycomment", + "{\"name\":\"legacycomment\",\"comments\":\"new markup\"}", 400); + + sendRequest(DELETE, "/groups/legacycomment", 204); + } + + @Test + public void testPreExistingUnknownMemberStaysEditable() throws Exception { + // a hand-edited groups.xml may reference a user that no longer exists; + // the group must remain editable while that member round-trips + final Group group = new Group(); + group.setName("stalemember"); + group.addUser("ghostuser"); + m_groupManager.saveGroup("stalemember", group); + + sendData(PUT, MediaType.APPLICATION_JSON, "/groups/stalemember", + "{\"name\":\"stalemember\",\"comments\":\"touched\",\"users\":[\"ghostuser\"]}", 204); + final JSONObject after = new JSONObject(getJson("/groups/stalemember", 200)); + assertEquals("touched", after.getString("comments")); + assertEquals("ghostuser", after.getJSONArray("users").getString(0)); + + // but ADDING a different unknown user is still rejected + sendData(PUT, MediaType.APPLICATION_JSON, "/groups/stalemember", + "{\"name\":\"stalemember\",\"users\":[\"ghostuser\",\"anotherghost\"]}", 400); + + sendRequest(DELETE, "/groups/stalemember", 204); + } + + @Test + public void testDotSegmentNamesRejected() throws Exception { + sendData(POST, MediaType.APPLICATION_JSON, "/groups", "{\"name\":\".\"}", 400); + sendData(POST, MediaType.APPLICATION_JSON, "/groups", "{\"name\":\"..\"}", 400); + } + + @Test + public void testCommentsCanBeCleared() throws Exception { + sendData(POST, MediaType.APPLICATION_JSON, "/groups", + "{\"name\":\"commentgroup\",\"comments\":\"to be removed\"}", 201); + // an explicit empty string clears; an omitted key preserves + sendData(PUT, MediaType.APPLICATION_JSON, "/groups/commentgroup", + "{\"name\":\"commentgroup\",\"comments\":\"\"}", 204); + final String json = getJson("/groups/commentgroup", 200); + assertTrue(!new JSONObject(json).has("comments") || new JSONObject(json).isNull("comments")); + sendRequest(DELETE, "/groups/commentgroup", 204); + } + + @Test + public void testUpdateReordersMembers() throws Exception { + ensureUser("first"); + ensureUser("second"); + sendData(POST, MediaType.APPLICATION_JSON, "/groups", + "{\"name\":\"ordergroup\",\"users\":[\"first\",\"second\"]}", 201); + + sendData(PUT, MediaType.APPLICATION_JSON, "/groups/ordergroup", + "{\"name\":\"ordergroup\",\"users\":[\"second\",\"first\"]}", 204); + + final JSONObject after = new JSONObject(getJson("/groups/ordergroup", 200)); + assertEquals("second", after.getJSONArray("users").getString(0)); + assertEquals("first", after.getJSONArray("users").getString(1)); + + sendRequest(DELETE, "/groups/ordergroup", 204); + } + + @Test + public void testPartialUpdatePreservesOmittedLists() throws Exception { + ensureUser("keepme"); + sendData(POST, MediaType.APPLICATION_JSON, "/groups", + "{\"name\":\"partialgroup\",\"users\":[\"keepme\"],\"dutySchedules\":[\"MoWeFr800-1700\"]}", 201); + + // a body that omits the user and dutySchedules keys must preserve both + sendData(PUT, MediaType.APPLICATION_JSON, "/groups/partialgroup", + "{\"name\":\"partialgroup\",\"comments\":\"updated\"}", 204); + + final JSONObject after = new JSONObject(getJson("/groups/partialgroup", 200)); + assertEquals("updated", after.getString("comments")); + assertEquals("keepme", after.getJSONArray("users").getString(0)); + assertEquals("MoWeFr800-1700", after.getJSONArray("dutySchedules").getString(0)); + + sendRequest(DELETE, "/groups/partialgroup", 204); + } + + @Test + public void testUpdatePreservesDefaultMap() throws Exception { + // default-map has no editor anywhere but exists in hand-edited files + final Group group = new Group(); + group.setName("mapgroup"); + group.setDefaultMap("some-map"); + m_groupManager.saveGroup("mapgroup", group); + + sendData(PUT, MediaType.APPLICATION_JSON, "/groups/mapgroup", + "{\"name\":\"mapgroup\",\"comments\":\"touched\"}", 204); + + assertEquals("some-map", m_groupManager.getGroup("mapgroup").getDefaultMap().orElse(null)); + sendRequest(DELETE, "/groups/mapgroup", 204); + } + + @Test + public void testRejectedUpdateLeavesNoPartialState() throws Exception { + sendData(POST, MediaType.APPLICATION_JSON, "/groups", + "{\"name\":\"atomicgroup\",\"comments\":\"original\"}", 201); + + // new comments arrive with an unknown member; nothing may be applied + sendData(PUT, MediaType.APPLICATION_JSON, "/groups/atomicgroup", + "{\"name\":\"atomicgroup\",\"comments\":\"changed\",\"users\":[\"nosuchuser\"]}", 400); + + final JSONObject after = new JSONObject(getJson("/groups/atomicgroup", 200)); + assertEquals("original", after.getString("comments")); + assertEquals("original", m_groupManager.getGroup("atomicgroup").getComments().orElse(null)); + + sendRequest(DELETE, "/groups/atomicgroup", 204); + } + + @Test + public void testBodyPathNameMismatchRejected() throws Exception { + sendData(PUT, MediaType.APPLICATION_JSON, "/groups/Admin", + "{\"name\":\"somebody-else\",\"comments\":\"x\"}", 400); + } + + @Test + public void testRename() throws Exception { + sendData(POST, MediaType.APPLICATION_JSON, "/groups", "{\"name\":\"renamegroup\"}", 201); + sendData(POST, MediaType.APPLICATION_JSON, "/groups", "{\"name\":\"occupiedgroup\"}", 201); + + sendData(POST, MediaType.APPLICATION_JSON, "/groups/renamegroup/rename", "{\"newName\":\"occupiedgroup\"}", 400); + sendData(POST, MediaType.APPLICATION_JSON, "/groups/renamegroup/rename", "{\"newName\":\"bad\"}", 400); + + sendData(POST, MediaType.APPLICATION_JSON, "/groups/renamegroup/rename", "{\"newName\":\"renamedgroup\"}", 204); + sendRequest(GET, "/groups/renamegroup", 404); + sendRequest(GET, "/groups/renamedgroup", 200); + + sendRequest(DELETE, "/groups/renamedgroup", 204); + sendRequest(DELETE, "/groups/occupiedgroup", 204); + } + + @Test + public void testRenameFollowsRoleReferences() throws Exception { + sendData(POST, MediaType.APPLICATION_JSON, "/groups", "{\"name\":\"rolegroup\"}", 201); + final Role role = new Role(); + role.setName("junit-oncall-role"); + role.setMembershipGroup("rolegroup"); + role.setSupervisor("admin"); + m_groupManager.saveRole(role); + + sendData(POST, MediaType.APPLICATION_JSON, "/groups/rolegroup/rename", "{\"newName\":\"rolegroup2\"}", 204); + + assertEquals("rolegroup2", m_groupManager.getRole("junit-oncall-role").getMembershipGroup()); + + // and delete is rejected while the role still references the group + sendRequest(DELETE, "/groups/rolegroup2", 400); + m_groupManager.deleteRole("junit-oncall-role"); + sendRequest(DELETE, "/groups/rolegroup2", 204); + } + + @Test + public void testAdminGroupProtections() throws Exception { + sendRequest(DELETE, "/groups/Admin", 400); + sendData(POST, MediaType.APPLICATION_JSON, "/groups/Admin/rename", "{\"newName\":\"Admins2\"}", 400); + sendRequest(GET, "/groups/Admin", 200); + } + + @Test + public void testForbiddenForNonAdmin() throws Exception { + setUser("nobody", new String[]{ "ROLE_USER" }); + try { + sendRequest(GET, "/groups", 403); + sendData(POST, MediaType.APPLICATION_JSON, "/groups", "{\"name\":\"x\"}", 403); + sendRequest(DELETE, "/groups/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..f5d471005853 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,12 @@ + + + + + + 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..6659a3652885 100644 --- a/smoke-test/src/test/java/org/opennms/smoketest/MenuHeaderIT.java +++ b/smoke-test/src/test/java/org/opennms/smoketest/MenuHeaderIT.java @@ -165,7 +165,8 @@ public void testMenuEntries() throws Exception { wait.until(ExpectedConditions.visibilityOfElementLocated(By.xpath("//ol[@class='breadcrumb']/li[contains(text()[normalize-space()], 'User List')]"))); clickMenuItem("User Management", "Manage Groups"); - wait.until(ExpectedConditions.visibilityOfElementLocated(By.xpath("//ol[@class='breadcrumb']/li[contains(text()[normalize-space()], 'Group List')]"))); + // now the Vue page (ui/index.html) + wait.until(ExpectedConditions.visibilityOfElementLocated(By.xpath("//h1[@class='page-title' and text()='Manage Groups']"))); clickMenuItem("User Management", "Manage On-call Roles"); wait.until(ExpectedConditions.visibilityOfElementLocated(By.xpath("//ol[@class='breadcrumb']/li[contains(text()[normalize-space()], 'Role List')]"))); diff --git a/ui/src/components/Common/AboutDialogButton.vue b/ui/src/components/Common/AboutDialogButton.vue new file mode 100644 index 000000000000..e936fa823520 --- /dev/null +++ b/ui/src/components/Common/AboutDialogButton.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/ui/src/components/ManageGroups/GroupEditorDialog.vue b/ui/src/components/ManageGroups/GroupEditorDialog.vue new file mode 100644 index 000000000000..039dc95f2da4 --- /dev/null +++ b/ui/src/components/ManageGroups/GroupEditorDialog.vue @@ -0,0 +1,328 @@ + + + + + diff --git a/ui/src/components/ManageGroups/GroupRenameDialog.vue b/ui/src/components/ManageGroups/GroupRenameDialog.vue new file mode 100644 index 000000000000..a32aa431d957 --- /dev/null +++ b/ui/src/components/ManageGroups/GroupRenameDialog.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/ui/src/components/ManageGroups/GroupsAbout.vue b/ui/src/components/ManageGroups/GroupsAbout.vue new file mode 100644 index 000000000000..f6487f4dc35d --- /dev/null +++ b/ui/src/components/ManageGroups/GroupsAbout.vue @@ -0,0 +1,35 @@ + + + diff --git a/ui/src/components/ManageGroups/GroupsTable.vue b/ui/src/components/ManageGroups/GroupsTable.vue new file mode 100644 index 000000000000..4873e5f15ed3 --- /dev/null +++ b/ui/src/components/ManageGroups/GroupsTable.vue @@ -0,0 +1,256 @@ + + + + + diff --git a/ui/src/containers/ManageGroups.vue b/ui/src/containers/ManageGroups.vue new file mode 100644 index 000000000000..8bc46212511a --- /dev/null +++ b/ui/src/containers/ManageGroups.vue @@ -0,0 +1,52 @@ + + + + + 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..bc69429157af 100644 --- a/ui/src/main/router/index.ts +++ b/ui/src/main/router/index.ts @@ -158,6 +158,25 @@ const router = createRouter({ } } }, + { + path: '/admin/groups', + name: 'Manage Groups', + component: () => import('@/containers/ManageGroups.vue'), + beforeEnter: (to, from) => { + const checkRoles = () => { + if (!adminRole.value) { + showSnackBar({ msg: 'Must be admin to manage groups.' }) + router.push(from.path) + } + } + + if (rolesAreLoaded.value) { + checkRoles() + } else { + whenever(rolesAreLoaded, () => checkRoles()) + } + } + }, { path: '/map', name: 'Map', diff --git a/ui/src/services/groupAdminService.ts b/ui/src/services/groupAdminService.ts new file mode 100644 index 000000000000..6563bef4a3d5 --- /dev/null +++ b/ui/src/services/groupAdminService.ts @@ -0,0 +1,130 @@ +/// +/// 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 { ManagedGroup } from '@/types/groupAdmin' +import { rest, v2 } from './axiosInstances' + +const { showSnackBar } = useSnackbar() +const { startSpinner, stopSpinner } = useSpinner() +const endpoint = '/groups' + +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 getManagedGroups = async (): Promise => { + try { + startSpinner() + const resp = await v2.get(endpoint) + return Array.isArray(resp.data) ? resp.data : [] + } catch (_err) { + showSnackBar({ msg: 'Failed to load groups.' }) + return null + } finally { + stopSpinner() + } +} + +// member picker options; the v1 users endpoint exists on every install +const getGroupMemberCandidates = async (): Promise => { + try { + const resp = await rest.get('/users?limit=0') + const users = resp.data?.users ?? [] + return (Array.isArray(users) ? users : [users]).map((u: any) => u['user-id']).filter(Boolean) + } catch (_err) { + showSnackBar({ msg: 'Failed to load users.' }) + return [] + } +} + +const createManagedGroup = async (group: ManagedGroup): Promise => { + try { + startSpinner() + await v2.post(endpoint, group) + showSnackBar({ msg: `Group '${group.name}' created.` }) + return null + } catch (err: any) { + const msg = errorMessage(err, `Failed to create group '${group.name}'.`) + showSnackBar({ msg, error: true }) + return msg + } finally { + stopSpinner() + } +} + +const updateManagedGroup = async (group: ManagedGroup): Promise => { + try { + startSpinner() + await v2.put(`${endpoint}/${encodeURIComponent(group.name)}`, group) + showSnackBar({ msg: `Group '${group.name}' updated.` }) + return null + } catch (err: any) { + const msg = errorMessage(err, `Failed to update group '${group.name}'.`) + showSnackBar({ msg, error: true }) + return msg + } finally { + stopSpinner() + } +} + +const renameManagedGroup = async (name: string, newName: string): Promise => { + try { + startSpinner() + await v2.post(`${endpoint}/${encodeURIComponent(name)}/rename`, { newName }) + showSnackBar({ msg: `Group '${name}' renamed to '${newName}'.` }) + return null + } catch (err: any) { + const msg = errorMessage(err, `Failed to rename group '${name}'.`) + showSnackBar({ msg, error: true }) + return msg + } finally { + stopSpinner() + } +} + +const deleteManagedGroup = async (name: string): Promise => { + try { + startSpinner() + await v2.delete(`${endpoint}/${encodeURIComponent(name)}`) + showSnackBar({ msg: `Group '${name}' deleted.` }) + return null + } catch (err: any) { + const msg = errorMessage(err, `Failed to delete group '${name}'.`) + showSnackBar({ msg, error: true }) + return msg + } finally { + stopSpinner() + } +} + +export { + createManagedGroup, + deleteManagedGroup, + getGroupMemberCandidates, + getManagedGroups, + renameManagedGroup, + updateManagedGroup +} diff --git a/ui/src/services/index.ts b/ui/src/services/index.ts index 8b8c60f273ea..35638b533e0d 100644 --- a/ui/src/services/index.ts +++ b/ui/src/services/index.ts @@ -74,6 +74,14 @@ import { setUsageStatisticsStatus } from './usageStatisticsService' import { addZenithRegistration, getZenithRegistrations } from './zenithConnectService' +import { + createManagedGroup, + deleteManagedGroup, + getGroupMemberCandidates, + getManagedGroups, + renameManagedGroup, + updateManagedGroup +} from './groupAdminService' export default { search, @@ -135,5 +143,11 @@ export default { setUsageStatisticsStatus, addZenithRegistration, getZenithRegistrations, - performLogout + performLogout, + createManagedGroup, + deleteManagedGroup, + getGroupMemberCandidates, + getManagedGroups, + renameManagedGroup, + updateManagedGroup } diff --git a/ui/src/stores/groupAdminStore.ts b/ui/src/stores/groupAdminStore.ts new file mode 100644 index 000000000000..10965416000d --- /dev/null +++ b/ui/src/stores/groupAdminStore.ts @@ -0,0 +1,90 @@ +/// +/// 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 { ManagedGroup } from '@/types/groupAdmin' +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const useGroupAdminStore = defineStore('groupAdminStore', () => { + const groups = ref([] as ManagedGroup[]) + const memberCandidates = ref([] as string[]) + + const getGroups = async () => { + const result = await API.getManagedGroups() + if (result !== null) { + groups.value = result + } + } + + const getMemberCandidates = async () => { + memberCandidates.value = await API.getGroupMemberCandidates() + } + + const createGroup = async (group: ManagedGroup) => { + const error = await API.createManagedGroup(group) + if (error === null) { + await getGroups() + } + return error + } + + const updateGroup = async (group: ManagedGroup) => { + const error = await API.updateManagedGroup(group) + if (error === null) { + await getGroups() + } + return error + } + + const renameGroup = async (name: string, newName: string) => { + const error = await API.renameManagedGroup(name, newName) + if (error === null) { + await getGroups() + } + return error + } + + const deleteGroup = async (name: string) => { + const error = await API.deleteManagedGroup(name) + if (error === null) { + await getGroups() + } + return error + } + + const populate = async () => { + await Promise.all([getGroups(), getMemberCandidates()]) + } + + return { + groups, + memberCandidates, + getGroups, + getMemberCandidates, + createGroup, + updateGroup, + renameGroup, + deleteGroup, + populate + } +}) diff --git a/ui/src/types/groupAdmin.ts b/ui/src/types/groupAdmin.ts new file mode 100644 index 000000000000..a2c2d83354f0 --- /dev/null +++ b/ui/src/types/groupAdmin.ts @@ -0,0 +1,37 @@ +/// +/// 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 group management API (/api/v2/groups). Field names +// follow groups.xml; the user list is ordered — the order drives notification +// escalation. Fields the API does not expose (default-map) are preserved +// server-side on update. + +export interface ManagedGroup { + name: string + comments?: string | null + users?: string[] + dutySchedules?: string[] +} + +// The server refuses to delete or rename this group; mirrored here so the UI +// can disable the controls with an explanation instead of a 400. +export const PROTECTED_GROUP_NAMES = ['Admin'] diff --git a/ui/tests/components/AdminDialogs/GroupEditorDialog.test.ts b/ui/tests/components/AdminDialogs/GroupEditorDialog.test.ts new file mode 100644 index 000000000000..3bd463129805 --- /dev/null +++ b/ui/tests/components/AdminDialogs/GroupEditorDialog.test.ts @@ -0,0 +1,105 @@ +import GroupEditorDialog from '@/components/ManageGroups/GroupEditorDialog.vue' +import { useGroupAdminStore } from '@/stores/groupAdminStore' +import { flushPromises, mount, VueWrapper } from '@vue/test-utils' +import PrimeVue from 'primevue/config' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/stores/groupAdminStore') + +// PrimeVue's Dialog teleports its content; a passthrough stub keeps the form +// and footer in the wrapper so the dialog's own behavior can be asserted. +const DialogStub = { + name: 'Dialog', + props: ['visible', 'header', 'modal'], + template: '
' +} + +describe('GroupEditorDialog.vue', () => { + let wrapper: VueWrapper + let store: any + + const mountDialog = async (group: any = null) => { + wrapper = mount(GroupEditorDialog, { + props: { visible: false, group }, + global: { + plugins: [PrimeVue], + stubs: { Dialog: DialogStub } + } + }) + await wrapper.setProps({ visible: true }) + await flushPromises() + } + + beforeEach(() => { + vi.clearAllMocks() + store = { + memberCandidates: ['admin', 'jose'], + createGroup: vi.fn().mockResolvedValue(null), + updateGroup: vi.fn().mockResolvedValue(null) + } + vi.mocked(useGroupAdminStore).mockReturnValue(store) + }) + + it('flags a group name with whitespace and disables saving', async () => { + await mountDialog() + await wrapper.find('[data-test="group-name-input"]').setValue('Test Group') + + expect(wrapper.find('#group-editor-name-error').text()).toContain('must not contain') + expect(wrapper.find('[data-test="save-button"]').attributes('disabled')).toBeDefined() + expect(store.createGroup).not.toHaveBeenCalled() + }) + + it('flags markup in the comments and disables saving', async () => { + await mountDialog() + await wrapper.find('[data-test="group-name-input"]').setValue('TestGroup') + await wrapper.find('[data-test="group-comments-input"]').setValue('styled') + + expect(wrapper.find('#group-editor-comments-error').exists()).toBe(true) + expect(wrapper.find('[data-test="save-button"]').attributes('disabled')).toBeDefined() + }) + + it('keeps a group with a pre-existing markup comment editable', async () => { + await mountDialog({ name: 'Legacy', comments: 'Bob\'s R&D team', users: [] }) + + expect(wrapper.find('#group-editor-comments-error').exists()).toBe(false) + expect(wrapper.find('[data-test="save-button"]').attributes('disabled')).toBeUndefined() + + await wrapper.find('[data-test="group-comments-input"]').setValue('changed') + expect(wrapper.find('#group-editor-comments-error').exists()).toBe(true) + }) + + it('submits a valid group and closes', async () => { + await mountDialog() + await wrapper.find('[data-test="group-name-input"]').setValue('TestGroup') + await wrapper.find('[data-test="group-comments-input"]').setValue('Test Group') + await wrapper.find('[data-test="save-button"]').trigger('click') + await flushPromises() + + expect(store.createGroup).toHaveBeenCalledWith(expect.objectContaining({ name: 'TestGroup', comments: 'Test Group' })) + expect(wrapper.emitted('update:visible')?.at(-1)).toEqual([false]) + }) + + it('shows a server rejection inside the dialog and stays open', async () => { + store.createGroup.mockResolvedValue('Group TestGroup already exists.') + await mountDialog() + await wrapper.find('[data-test="group-name-input"]').setValue('TestGroup') + 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([]) + }) + + it('clears a previous error when reopened', async () => { + store.createGroup.mockResolvedValue('rejected') + await mountDialog() + await wrapper.find('[data-test="group-name-input"]').setValue('TestGroup') + await wrapper.find('[data-test="save-button"]').trigger('click') + await flushPromises() + expect(wrapper.find('[data-test="dialog-error"]').exists()).toBe(true) + + await wrapper.setProps({ visible: false }) + await wrapper.setProps({ visible: true }) + expect(wrapper.find('[data-test="dialog-error"]').exists()).toBe(false) + }) +}) 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/groupAdminStore.test.ts b/ui/tests/stores/groupAdminStore.test.ts new file mode 100644 index 000000000000..097720bf60f8 --- /dev/null +++ b/ui/tests/stores/groupAdminStore.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useGroupAdminStore } from '@/stores/groupAdminStore' +import API from '@/services' +import { ManagedGroup } from '@/types/groupAdmin' + +vi.mock('@/services', () => ({ + default: { + getManagedGroups: vi.fn(), + getGroupMemberCandidates: vi.fn(), + createManagedGroup: vi.fn(), + updateManagedGroup: vi.fn(), + renameManagedGroup: vi.fn(), + deleteManagedGroup: vi.fn() + } +})) + +describe('useGroupAdminStore', () => { + let store: ReturnType + + const mockGroups: ManagedGroup[] = [ + { name: 'Admin', comments: 'The administrators', users: ['admin'] }, + { name: 'NOC', users: ['second', 'first'] } + ] + + beforeEach(() => { + setActivePinia(createPinia()) + store = useGroupAdminStore() + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should start empty', () => { + expect(store.groups).toEqual([]) + expect(store.memberCandidates).toEqual([]) + }) + + it('populate should load groups and member candidates', async () => { + vi.mocked(API.getManagedGroups).mockResolvedValue(mockGroups) + vi.mocked(API.getGroupMemberCandidates).mockResolvedValue(['admin', 'first', 'second']) + + await store.populate() + + expect(store.groups).toEqual(mockGroups) + expect(store.memberCandidates).toEqual(['admin', 'first', 'second']) + }) + + it('a failed refresh should keep the previous group list', async () => { + vi.mocked(API.getManagedGroups).mockResolvedValue(mockGroups) + await store.getGroups() + expect(store.groups).toEqual(mockGroups) + + vi.mocked(API.getManagedGroups).mockResolvedValue(null) + await store.getGroups() + expect(store.groups).toEqual(mockGroups) + }) + + it('createGroup should refresh on success and preserve member order in the payload', async () => { + vi.mocked(API.createManagedGroup).mockResolvedValue(null) + vi.mocked(API.getManagedGroups).mockResolvedValue(mockGroups) + + const group: ManagedGroup = { name: 'NOC', users: ['second', 'first'] } + const ok = await store.createGroup(group) + + expect(ok).toBe(null) + expect(API.createManagedGroup).toHaveBeenCalledWith(group) + expect(vi.mocked(API.createManagedGroup).mock.calls[0][0].users).toEqual(['second', 'first']) + expect(API.getManagedGroups).toHaveBeenCalledTimes(1) + }) + + it('createGroup should not refresh on failure', async () => { + vi.mocked(API.createManagedGroup).mockResolvedValue('it failed') + + const ok = await store.createGroup({ name: 'NOC' }) + + expect(ok).toBe('it failed') + expect(API.getManagedGroups).not.toHaveBeenCalled() + }) + + it('updateGroup should refresh on success', async () => { + vi.mocked(API.updateManagedGroup).mockResolvedValue(null) + vi.mocked(API.getManagedGroups).mockResolvedValue(mockGroups) + + await store.updateGroup(mockGroups[1]) + + expect(API.updateManagedGroup).toHaveBeenCalledWith(mockGroups[1]) + expect(API.getManagedGroups).toHaveBeenCalledTimes(1) + }) + + it('renameGroup should pass old and new names and refresh', async () => { + vi.mocked(API.renameManagedGroup).mockResolvedValue(null) + vi.mocked(API.getManagedGroups).mockResolvedValue(mockGroups) + + await store.renameGroup('NOC', 'NOC2') + + expect(API.renameManagedGroup).toHaveBeenCalledWith('NOC', 'NOC2') + expect(API.getManagedGroups).toHaveBeenCalledTimes(1) + }) + + it('deleteGroup should refresh on success', async () => { + vi.mocked(API.deleteManagedGroup).mockResolvedValue(null) + vi.mocked(API.getManagedGroups).mockResolvedValue([mockGroups[0]]) + + await store.deleteGroup('NOC') + + expect(store.groups).toEqual([mockGroups[0]]) + }) +})