diff --git a/gms/src/main/java/it/inaf/ia2/gms/authn/SecurityConfig.java b/gms/src/main/java/it/inaf/ia2/gms/authn/SecurityConfig.java index c1b4c6a54f6cf8b42b984e0f92cef3b5059b235f..8da99f12dcf06195ad4d7493309530cfaf19f9b3 100644 --- a/gms/src/main/java/it/inaf/ia2/gms/authn/SecurityConfig.java +++ b/gms/src/main/java/it/inaf/ia2/gms/authn/SecurityConfig.java @@ -12,6 +12,7 @@ import org.springframework.core.Ordered; import org.springframework.core.env.Environment; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.builders.WebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -32,6 +33,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter { super.configure(http); + // CORS are necessary only for development (API access from npm server) if (Arrays.asList(env.getActiveProfiles()).contains("dev")) { http.authorizeRequests() .antMatchers(HttpMethod.OPTIONS, "/**").permitAll(); @@ -40,6 +42,31 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter { http.csrf().disable(); } + /** + * The authentication is ignored for these endpoints. The "/ws" endpoints + * (web service API for programmatic access) are protected by the custom + * WebServiceAuthorizationFilter that checks BasicAuth for GMS clients. + */ + @Override + public void configure(WebSecurity web) throws Exception { + web.ignoring().antMatchers("/ws/**", "/error"); + } + + /** + * Checks the BasicAuth for GMS clients. + */ + @Bean + public FilterRegistrationBean webServiceAuthorizationFilter() { + FilterRegistrationBean bean = new FilterRegistrationBean(); + bean.setFilter(new WebServiceAuthorizationFilter()); + bean.addUrlPatterns("/ws/*"); + bean.setOrder(Ordered.HIGHEST_PRECEDENCE); + return bean; + } + + /** + * CORS are necessary only for development (API access from npm server). + */ @Bean @Profile("dev") public FilterRegistrationBean corsFilter() { diff --git a/gms/src/main/java/it/inaf/ia2/gms/authn/WebServiceAuthorizationFilter.java b/gms/src/main/java/it/inaf/ia2/gms/authn/WebServiceAuthorizationFilter.java new file mode 100644 index 0000000000000000000000000000000000000000..cb9118519be15bd46acfffcfaf2aa6d1d72a8746 --- /dev/null +++ b/gms/src/main/java/it/inaf/ia2/gms/authn/WebServiceAuthorizationFilter.java @@ -0,0 +1,94 @@ +package it.inaf.ia2.gms.authn; + +import it.inaf.ia2.gms.exception.UnauthorizedException; +import it.inaf.ia2.gms.persistence.ClientsDAO; +import it.inaf.ia2.gms.persistence.model.ClientEntity; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.xml.bind.DatatypeConverter; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.context.support.WebApplicationContextUtils; + +public class WebServiceAuthorizationFilter implements Filter { + + @Override + public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { + + HttpServletRequest request = (HttpServletRequest) req; + + if (request.getServletPath().startsWith("/ws/")) { + try { + validateBasicAuth(request); + } catch (UnauthorizedException ex) { + ((HttpServletResponse) res).sendError(HttpServletResponse.SC_UNAUTHORIZED, ex.getMessage()); + return; + } + } + + chain.doFilter(req, res); + } + + private void validateBasicAuth(HttpServletRequest request) { + + String token = getBasicAuthToken(request); + + int delim = token.indexOf(":"); + + if (delim == -1) { + throw new BadCredentialsException("Invalid basic authentication token"); + } + + String clientId = token.substring(0, delim); + String clientSecret = token.substring(delim + 1); + + ClientsDAO clientsDAO = getClientsDAO(request); + + ClientEntity client = clientsDAO.findClientById(clientId) + .orElseThrow(() -> new BadCredentialsException("Client " + clientId + " not found")); + + String shaSecret = getSha256(clientSecret); + if (!shaSecret.equals(client.getSecret())) { + throw new UnauthorizedException("Wrong secret"); + } + } + + private String getBasicAuthToken(HttpServletRequest request) { + + String header = request.getHeader("Authorization"); + + if (header == null || !header.toLowerCase().startsWith("basic ")) { + throw new UnauthorizedException("Missing Authorization header"); + } + + byte[] base64Token = header.substring(6).getBytes(StandardCharsets.UTF_8); + byte[] decoded = Base64.getDecoder().decode(base64Token); + + return new String(decoded, StandardCharsets.UTF_8); + } + + protected ClientsDAO getClientsDAO(HttpServletRequest request) { + WebApplicationContext webApplicationContext = WebApplicationContextUtils.getWebApplicationContext(request.getServletContext()); + return webApplicationContext.getBean(ClientsDAO.class); + } + + private static String getSha256(String secret) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] sha = md.digest(secret.getBytes(StandardCharsets.UTF_8)); + return DatatypeConverter.printHexBinary(sha).toLowerCase(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/gms/src/main/java/it/inaf/ia2/gms/controller/GroupsController.java b/gms/src/main/java/it/inaf/ia2/gms/controller/GroupsController.java index 8eb6f6469056335110af38d961de532da5d60067..11b05b0fd4492be14f7c2fd4aa507d57338e29f0 100644 --- a/gms/src/main/java/it/inaf/ia2/gms/controller/GroupsController.java +++ b/gms/src/main/java/it/inaf/ia2/gms/controller/GroupsController.java @@ -1,17 +1,20 @@ package it.inaf.ia2.gms.controller; import it.inaf.ia2.gms.authn.SessionData; +import it.inaf.ia2.gms.exception.UnauthorizedException; import it.inaf.ia2.gms.model.CreateGroupRequest; import it.inaf.ia2.gms.model.GroupNode; import it.inaf.ia2.gms.model.GroupsModelRequest; import it.inaf.ia2.gms.model.GroupsModelResponse; import it.inaf.ia2.gms.model.PaginatedData; import it.inaf.ia2.gms.model.PaginatedModelRequest; +import it.inaf.ia2.gms.model.Permission; import it.inaf.ia2.gms.model.RenameGroupRequest; import it.inaf.ia2.gms.persistence.model.GroupEntity; import it.inaf.ia2.gms.service.GroupsModelBuilder; import it.inaf.ia2.gms.service.GroupsService; import it.inaf.ia2.gms.service.GroupsTreeBuilder; +import it.inaf.ia2.gms.service.PermissionsService; import javax.validation.Valid; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; @@ -31,6 +34,9 @@ public class GroupsController { @Autowired private SessionData session; + @Autowired + private PermissionsService permissionsService; + @Autowired private GroupsService groupsService; @@ -48,7 +54,13 @@ public class GroupsController { @PostMapping(value = "/group", consumes = MediaType.APPLICATION_JSON_UTF8_VALUE, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) public ResponseEntity<PaginatedData<GroupNode>> createGroup(@Valid @RequestBody CreateGroupRequest request) { - GroupEntity newGroup = groupsService.addGroup(request.getParentGroupId(), request.getNewGroupName(), session.getUserId()); + GroupEntity parent = groupsService.getGroupById(request.getParentGroupId()); + + if (permissionsService.getGroupPermission(parent, session.getUserId()) != Permission.ADMIN) { + throw new UnauthorizedException("Missing admin privileges"); + } + + GroupEntity newGroup = groupsService.addGroup(parent, request.getNewGroupName()); PaginatedData<GroupNode> groupsPanel = getGroupsPanel(request.getParentGroupId(), request); diff --git a/gms/src/main/java/it/inaf/ia2/gms/controller/WebServiceController.java b/gms/src/main/java/it/inaf/ia2/gms/controller/WebServiceController.java new file mode 100644 index 0000000000000000000000000000000000000000..a6ff8c83e3d6758c542ccec4d434595d0b4ba31c --- /dev/null +++ b/gms/src/main/java/it/inaf/ia2/gms/controller/WebServiceController.java @@ -0,0 +1,66 @@ +package it.inaf.ia2.gms.controller; + +import it.inaf.ia2.gms.persistence.model.GroupEntity; +import it.inaf.ia2.gms.service.GroupsService; +import java.util.List; +import java.util.Optional; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/ws") +public class WebServiceController { + + @Autowired + private GroupsService groupsService; + + /** + * Creates a group and its ancestors if they are missing. It doesn't fail if + * the last group already exists. + */ + @PostMapping("/group") + public ResponseEntity<GroupEntity> createGroup(@RequestBody List<String> names) { + + GroupEntity group = groupsService.getRoot(); + + for (String name : names) { + Optional<GroupEntity> optGroup = groupsService.findGroupByParentAndName(group, name); + if (optGroup.isPresent()) { + group = optGroup.get(); + } else { + group = groupsService.addGroup(group, name); + } + } + + return new ResponseEntity<>(group, HttpStatus.CREATED); + } + + public void deleteGroup() { + + } + + public void addMember() { + + } + + public void removeMember() { + + } + + public void addPrivilege() { + + } + + public void deletePrivilege() { + + } + + public void prepareToJoin() { + + } +} diff --git a/gms/src/main/java/it/inaf/ia2/gms/persistence/ClientsDAO.java b/gms/src/main/java/it/inaf/ia2/gms/persistence/ClientsDAO.java new file mode 100644 index 0000000000000000000000000000000000000000..0ceb032665fcd9b14a0f9ff2719f71f0fd3e9e4e --- /dev/null +++ b/gms/src/main/java/it/inaf/ia2/gms/persistence/ClientsDAO.java @@ -0,0 +1,58 @@ +package it.inaf.ia2.gms.persistence; + +import it.inaf.ia2.gms.persistence.model.ClientEntity; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import javax.sql.DataSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +@Component +public class ClientsDAO { + + private final JdbcTemplate jdbcTemplate; + + @Autowired + public ClientsDAO(DataSource dataSource) { + jdbcTemplate = new JdbcTemplate(dataSource); + } + + public Optional<ClientEntity> findClientById(String clientId) { + + String sql = "SELECT client_secret, allowed_actions, ip_filter FROM gms_client WHERE client_id = ?"; + + return jdbcTemplate.query(conn -> { + PreparedStatement ps = conn.prepareStatement(sql); + ps.setString(1, clientId); + return ps; + }, resultSet -> { + if (resultSet.next()) { + ClientEntity client = new ClientEntity(); + client.setId(clientId); + client.setSecret(resultSet.getString("client_secret")); + client.setAllowedActions(getAllowedActions(resultSet)); + client.setIpFilter(resultSet.getString("ip_filter")); + return Optional.of(client); + } + return Optional.empty(); + }); + } + + private List<String> getAllowedActions(ResultSet resultSet) throws SQLException { + + List<String> actions = new ArrayList<>(); + + ResultSet items = resultSet.getArray("allowed_actions").getResultSet(); + while (items.next()) { + String action = items.getString(1); + actions.add(action); + } + + return actions; + } +} diff --git a/gms/src/main/java/it/inaf/ia2/gms/persistence/GroupsDAO.java b/gms/src/main/java/it/inaf/ia2/gms/persistence/GroupsDAO.java index 88f85098ffd628212f9d6db6bfee2be091c9791f..9f84eaed7a9a47cd1ffc4f6f1b02ff6f58891eb6 100644 --- a/gms/src/main/java/it/inaf/ia2/gms/persistence/GroupsDAO.java +++ b/gms/src/main/java/it/inaf/ia2/gms/persistence/GroupsDAO.java @@ -101,6 +101,27 @@ public class GroupsDAO { }); } + public Optional<GroupEntity> findGroupByParentAndName(String parentPath, String childName) { + + String sql = "SELECT id, path from gms_group WHERE name = ? AND path ~ ?"; + + return jdbcTemplate.query(conn -> { + PreparedStatement ps = conn.prepareStatement(sql); + ps.setString(1, childName); + ps.setObject(2, getSubGroupsPath(parentPath), Types.OTHER); + return ps; + }, resultSet -> { + if (resultSet.next()) { + GroupEntity group = new GroupEntity(); + group.setId(resultSet.getString("id")); + group.setName(childName); + group.setPath(resultSet.getString("path")); + return Optional.of(group); + } + return Optional.empty(); + }); + } + public List<GroupEntity> listSubGroups(String path) { String sql = "SELECT id, name, path from gms_group WHERE path ~ ? ORDER BY name"; diff --git a/gms/src/main/java/it/inaf/ia2/gms/persistence/model/ClientEntity.java b/gms/src/main/java/it/inaf/ia2/gms/persistence/model/ClientEntity.java new file mode 100644 index 0000000000000000000000000000000000000000..eb00aa98d8c87699dae2179af96e4e7ca965f3a7 --- /dev/null +++ b/gms/src/main/java/it/inaf/ia2/gms/persistence/model/ClientEntity.java @@ -0,0 +1,43 @@ +package it.inaf.ia2.gms.persistence.model; + +import java.util.List; + +public class ClientEntity { + + private String id; + private String secret; + private List<String> allowedActions; + private String ipFilter; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getSecret() { + return secret; + } + + public void setSecret(String secret) { + this.secret = secret; + } + + public List<String> getAllowedActions() { + return allowedActions; + } + + public void setAllowedActions(List<String> allowedActions) { + this.allowedActions = allowedActions; + } + + public String getIpFilter() { + return ipFilter; + } + + public void setIpFilter(String ipFilter) { + this.ipFilter = ipFilter; + } +} diff --git a/gms/src/main/java/it/inaf/ia2/gms/service/GroupsService.java b/gms/src/main/java/it/inaf/ia2/gms/service/GroupsService.java index e37a4b6427674cc744314ad6e21c8891727d93de..12aa21656a099857ef6f1a67fa7c42e56de97876 100644 --- a/gms/src/main/java/it/inaf/ia2/gms/service/GroupsService.java +++ b/gms/src/main/java/it/inaf/ia2/gms/service/GroupsService.java @@ -9,6 +9,7 @@ import it.inaf.ia2.gms.model.Permission; import it.inaf.ia2.gms.persistence.GroupsDAO; import it.inaf.ia2.gms.persistence.model.GroupEntity; import java.util.List; +import java.util.Optional; import java.util.UUID; @Service @@ -37,13 +38,7 @@ public class GroupsService { } } - public GroupEntity addGroup(String parentId, String groupName, String userId) { - - GroupEntity parent = getGroupById(parentId); - - if (permissionsService.getGroupPermission(parent, userId) != Permission.ADMIN) { - throw new UnauthorizedException("Missing admin privileges"); - } + public GroupEntity addGroup(GroupEntity parent, String groupName) { if (groupsDAO.listSubGroups(parent.getPath()).stream() .anyMatch(g -> g.getName().equals(groupName))) { @@ -107,6 +102,10 @@ public class GroupsService { return parent; } + public GroupEntity getRoot() { + return getGroupById(ROOT); + } + public GroupEntity getGroupById(String groupId) { return groupsDAO.findGroupById(groupId) .orElseThrow(() -> new BadRequestException("Group " + groupId + " not found")); @@ -120,4 +119,8 @@ public class GroupsService { public List<GroupBreadcrumb> getBreadcrumbs(String path) { return groupsDAO.getBreadcrumbs(path); } + + public Optional<GroupEntity> findGroupByParentAndName(GroupEntity parent, String childName) { + return groupsDAO.findGroupByParentAndName(parent.getPath(), childName); + } } diff --git a/gms/src/main/resources/sql/init.sql b/gms/src/main/resources/sql/init.sql index 737c319d7d89f20011eef0c176823e2d2f689a1b..55d7def759ac75b3b19d3eafbfd635ff2632868e 100644 --- a/gms/src/main/resources/sql/init.sql +++ b/gms/src/main/resources/sql/init.sql @@ -29,3 +29,11 @@ CREATE TABLE gms_permission ( foreign key (group_id) references gms_group(id), foreign key (group_path) references gms_group(path) ); + +CREATE TABLE gms_client ( + client_id varchar NOT NULL, + client_secret varchar NOT NULL, + allowed_actions text[] NOT NULL, + ip_filter text NULL, + primary key (client_id) +); \ No newline at end of file diff --git a/gms/src/test/java/it/inaf/ia2/gms/authn/WebServiceAuthorizationFilterTest.java b/gms/src/test/java/it/inaf/ia2/gms/authn/WebServiceAuthorizationFilterTest.java new file mode 100644 index 0000000000000000000000000000000000000000..e3ff8469c4f487033a8cb4b5b6eb85296b185a20 --- /dev/null +++ b/gms/src/test/java/it/inaf/ia2/gms/authn/WebServiceAuthorizationFilterTest.java @@ -0,0 +1,98 @@ +package it.inaf.ia2.gms.authn; + +import it.inaf.ia2.gms.persistence.ClientsDAO; +import it.inaf.ia2.gms.persistence.model.ClientEntity; +import java.util.Collections; +import java.util.Optional; +import javax.servlet.FilterChain; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(JUnit4.class) +public class WebServiceAuthorizationFilterTest { + + private WebServiceAuthorizationFilter filter; + + private HttpServletRequest request; + private HttpServletResponse response; + private FilterChain chain; + + @Before + public void setUp() { + + ClientsDAO clientsDAO = mock(ClientsDAO.class); + + ClientEntity client = new ClientEntity(); + client.setId("test"); + client.setSecret("5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"); // sha256 of "password" + client.setAllowedActions(Collections.singletonList("*")); + + when(clientsDAO.findClientById("test")).thenReturn(Optional.of(client)); + + filter = spy(new WebServiceAuthorizationFilter()); + doReturn(clientsDAO).when(filter).getClientsDAO(any()); + + request = mock(HttpServletRequest.class); + response = mock(HttpServletResponse.class); + chain = mock(FilterChain.class); + } + + @Test + public void testValidCredentials() throws Exception { + + when(request.getServletPath()).thenReturn("/ws/group"); + when(request.getHeader("Authorization")).thenReturn("Basic dGVzdDpwYXNzd29yZA=="); // test:password + + filter.doFilter(request, response, chain); + + verify(chain, times(1)).doFilter(any(), any()); + } + + @Test + public void testInvalidCredentials() throws Exception { + + when(request.getServletPath()).thenReturn("/ws/group"); + when(request.getHeader("Authorization")).thenReturn("Basic dGVzdDp0ZXN0"); // test:test + + filter.doFilter(request, response, chain); + + verify(response, times(1)).sendError(eq(HttpServletResponse.SC_UNAUTHORIZED), any()); + verify(chain, never()).doFilter(any(), any()); + } + + @Test + public void testMissingHeader() throws Exception { + + when(request.getServletPath()).thenReturn("/ws/group"); + + filter.doFilter(request, response, chain); + + verify(response, times(1)).sendError(eq(HttpServletResponse.SC_UNAUTHORIZED), any()); + verify(chain, never()).doFilter(any(), any()); + } + + @Test + public void testOutsidePath() throws Exception { + + when(request.getServletPath()).thenReturn("/other/path"); + + filter.doFilter(request, response, chain); + + verify(response, never()).sendError(anyInt(), any()); + verify(chain, times(1)).doFilter(any(), any()); + } +} diff --git a/gms/src/test/java/it/inaf/ia2/gms/persistence/GroupsDAOTest.java b/gms/src/test/java/it/inaf/ia2/gms/persistence/GroupsDAOTest.java index 9d5ad1aa45da89c863eca21885e10d3d24b922d4..ae10ed1ef8f51555b6e7e356ae2737b835c919ca 100644 --- a/gms/src/test/java/it/inaf/ia2/gms/persistence/GroupsDAOTest.java +++ b/gms/src/test/java/it/inaf/ia2/gms/persistence/GroupsDAOTest.java @@ -88,6 +88,15 @@ public class GroupsDAOTest { assertEquals(1, groups.size()); assertEquals("INAF", groups.get(0).getName()); + // Group by parent and name + Optional<GroupEntity> optGroup = dao.findGroupByParentAndName(root.getPath(), lbt.getName()); + assertTrue(optGroup.isPresent()); + assertEquals(lbt.getId(), optGroup.get().getId()); + + optGroup = dao.findGroupByParentAndName(lbt.getPath(), lbtInaf.getName()); + assertTrue(optGroup.isPresent()); + assertEquals(lbtInaf.getId(), optGroup.get().getId()); + // Children map Map<String, Boolean> childrenMap = dao.getHasChildrenMap(Sets.newSet(root.getId())); assertEquals(1, childrenMap.size()); diff --git a/gms/src/test/java/it/inaf/ia2/gms/persistence/NestedGroupsIntegrationTest.java b/gms/src/test/java/it/inaf/ia2/gms/persistence/NestedGroupsIntegrationTest.java index 398cd3d97aeda2fc2dbdb3bf5d9aee3872ecb1a9..0a90226cd21fc2097aaefc54c39de43277c5c58f 100644 --- a/gms/src/test/java/it/inaf/ia2/gms/persistence/NestedGroupsIntegrationTest.java +++ b/gms/src/test/java/it/inaf/ia2/gms/persistence/NestedGroupsIntegrationTest.java @@ -49,13 +49,13 @@ public class NestedGroupsIntegrationTest { permissionsDAO.createPermission(superAdminPermission); // Setup groups - GroupEntity root = groupsService.getGroupById(GroupsService.ROOT); - GroupEntity lbt = groupsService.addGroup(GroupsService.ROOT, "LBT", userId); - GroupEntity tng = groupsService.addGroup(GroupsService.ROOT, "TNG", userId); - GroupEntity radio = groupsService.addGroup(GroupsService.ROOT, "Radio", userId); - GroupEntity lbtInaf = groupsService.addGroup(lbt.getId(), "INAF", userId); - GroupEntity lbtInafProgram1 = groupsService.addGroup(lbtInaf.getId(), "P1", userId); - GroupEntity lbtInafProgram2 = groupsService.addGroup(lbtInaf.getId(), "P2", userId); + GroupEntity root = groupsService.getRoot(); + GroupEntity lbt = groupsService.addGroup(root, "LBT"); + GroupEntity tng = groupsService.addGroup(root, "TNG"); + GroupEntity radio = groupsService.addGroup(root, "Radio"); + GroupEntity lbtInaf = groupsService.addGroup(lbt, "INAF"); + GroupEntity lbtInafProgram1 = groupsService.addGroup(lbtInaf, "P1"); + GroupEntity lbtInafProgram2 = groupsService.addGroup(lbtInaf, "P2"); PaginatedModelRequest request = new PaginatedModelRequest(); request.setPaginatorPage(1);