diff --git a/CHANGELOG.md b/CHANGELOG.md index 25a056c532647dc2e8806b4e9ebccfcdfd91dc65..9d2d7883229dd069113850e5512a34b7d8b737ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,9 @@ -* 2.0.13 +# 2.0.14 +* [LGR-75](https://jira.software.geant.org/browse/LGR-73) - Externalise saml groups to role +* [LGR-73](https://jira.software.geant.org/browse/LGR-73) - Remove router endpoint +* [LGR-72](https://jira.software.geant.org/browse/LGR-72) - Check user is authenticated prior to executing commands on internal routers + +# 2.0.13 * [LGR-70](https://jira.software.geant.org/browse/LGR-70) - Pull UI out of jar # 2.0.12 diff --git a/lombok.config b/lombok.config new file mode 100644 index 0000000000000000000000000000000000000000..f202d3031e2c69a6aa27c2cda1ad9fe29c690889 --- /dev/null +++ b/lombok.config @@ -0,0 +1,4 @@ +# tells Lombok that this is the root directory and that it shouldn’t search parent directories for more configuration files +config.stopBubbling = true +# tells Lombok to add @lombok.Generated annotation to all generated methods +lombok.addLombokGeneratedAnnotation = true \ No newline at end of file diff --git a/pom.xml b/pom.xml index 1dd100dcd082d0e1eeb019dca8f96cf5bf68a8e8..9a67ccd7ff5a28557bdf4dfb5918085b1fba9163 100644 --- a/pom.xml +++ b/pom.xml @@ -3,10 +3,10 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> - <groupId>org.geant</groupId> - <artifactId>looking-glass-service</artifactId> - <version>2.0.13.RELEASE</version> - <packaging>jar</packaging> + <groupId>org.geant</groupId> + <artifactId>looking-glass-service</artifactId> + <version>2.0.14.RELEASE</version> + <packaging>jar</packaging> <name>looking-glass-service</name> <description>Looking Glass Demo</description> @@ -14,7 +14,7 @@ <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> - <version>2.0.5.RELEASE</version> + <version>2.2.5.RELEASE</version> <relativePath/> <!-- lookup parent from repository --> </parent> @@ -25,25 +25,6 @@ <java.version>1.8</java.version> </properties> - <scm> - <developerConnection>scm:git:https://gitlab.geant.net/live-projects/looking-glass-service.git - </developerConnection> - <tag>HEAD</tag> - </scm> - - <distributionManagement> - <repository> - <id>looking-glass-releases</id> - <name>GEANT Artifactory-releases</name> - <url>https://artifactory.geant.net:443/artifactory/lg-release-local</url> - </repository> - <snapshotRepository> - <id>looking-glass-snapshots</id> - <name>GEANT Artifactory-snapshots</name> - <url>https://artifactory.geant.net:443/artifactory/lg-snapshot-local</url> - </snapshotRepository> - </distributionManagement> - <dependencies> <dependency> <groupId>org.springframework.boot</groupId> @@ -59,19 +40,6 @@ <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> - <dependency> - <groupId>org.springframework.boot</groupId> - <artifactId>spring-boot-starter-data-jpa</artifactId> - </dependency> - <dependency> - <groupId>mysql</groupId> - <artifactId>mysql-connector-java</artifactId> - <scope>runtime</scope> - </dependency> - <dependency> - <groupId>org.springframework.boot</groupId> - <artifactId>spring-boot-starter-data-rest</artifactId> - </dependency> <dependency> <groupId>org.springframework.boot</groupId> @@ -87,6 +55,11 @@ <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> + <dependency> + <groupId>org.springframework.security</groupId> + <artifactId>spring-security-test</artifactId> + <scope>test</scope> + </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-log4j2</artifactId> @@ -101,16 +74,6 @@ <artifactId>spring-security-saml2-core</artifactId> <version>1.0.3.RELEASE</version> </dependency> - <dependency> - <groupId>io.springfox</groupId> - <artifactId>springfox-swagger2</artifactId> - <version>2.7.0</version> - </dependency> - <dependency> - <groupId>io.springfox</groupId> - <artifactId>springfox-swagger-ui</artifactId> - <version>2.7.0</version> - </dependency> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> @@ -119,6 +82,50 @@ <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-statsd</artifactId> </dependency> + <dependency> + <groupId>com.squareup.retrofit2</groupId> + <artifactId>retrofit</artifactId> + <version>2.3.0</version> + </dependency> + <dependency> + <groupId>com.squareup.retrofit2</groupId> + <artifactId>converter-gson</artifactId> + <version>2.3.0</version> + </dependency> + <dependency> + <groupId>com.squareup.retrofit2</groupId> + <artifactId>converter-jackson</artifactId> + <version>2.8.0</version> + </dependency> + <dependency> + <groupId>com.tngtech.jgiven</groupId> + <artifactId>jgiven-junit</artifactId> + <version>0.18.2</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>com.tngtech.jgiven</groupId> + <artifactId>jgiven-spring</artifactId> + <version>0.18.2</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.assertj</groupId> + <artifactId>assertj-core</artifactId> + <version>3.14.0</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>com.alexlovett</groupId> + <artifactId>jgiven-extension</artifactId> + <version>0.0.1-SNAPSHOT</version> + </dependency> + <dependency> + <groupId>com.github.tomakehurst</groupId> + <artifactId>wiremock-jre8</artifactId> + <version>2.26.3</version> + <scope>test</scope> + </dependency> </dependencies> <build> @@ -138,7 +145,8 @@ <line>After=syslog.target</line> <line>[Service]</line> <line>User=${artifactId}</line> - <line>ExecStart=/opt/${artifactId}/latest/bin/${artifactId}.${project.packaging} --spring.config.location=file:/opt/${artifactId}/${version}/config/application.yml</line> + <line>ExecStart=/opt/${artifactId}/latest/bin/${artifactId}.${project.packaging} --spring.config.location=file:/opt/${artifactId}/${version}/config/application.yml + </line> <line>StandardOutput=syslog</line> <line>StandardError=syslog</line> <line>SuccessExitStatus=143</line> @@ -188,13 +196,15 @@ <version>0.8.5</version> <executions> <execution> + <id>pre-integration-test</id> + <phase>test-compile</phase> <goals> <goal>prepare-agent</goal> </goals> </execution> <execution> <id>report</id> - <phase>prepare-package</phase> + <phase>post-integration-test</phase> <goals> <goal>report</goal> </goals> @@ -277,8 +287,11 @@ <ruleset>default</ruleset> </entry> <entry> - <name>/opt/${artifactId}/${version}/bin/${project.build.finalName}.${project.packaging}</name> - <file>${project.build.directory}/${project.build.finalName}.${project.packaging}</file> + <name> + /opt/${artifactId}/${version}/bin/${project.build.finalName}.${project.packaging} + </name> + <file>${project.build.directory}/${project.build.finalName}.${project.packaging} + </file> <ruleset>default</ruleset> </entry> <entry> @@ -288,7 +301,9 @@ </entry> <entry> <name>/opt/${artifactId}/${version}/bin/${artifactId}.${project.packaging}</name> - <linkTo>/opt/${artifactId}/${version}/bin/${project.build.finalName}.${project.packaging}</linkTo> + <linkTo> + /opt/${artifactId}/${version}/bin/${project.build.finalName}.${project.packaging} + </linkTo> <ruleset>default</ruleset> </entry> <entry> @@ -301,6 +316,34 @@ </execution> </executions> </plugin> + <plugin> + <artifactId>maven-failsafe-plugin</artifactId> + <version>2.18.1</version> + <executions> + <execution> + <goals> + <goal>integration-test</goal> + <goal>verify</goal> + </goals> + </execution> + </executions> + </plugin> + <plugin> + <groupId>com.tngtech.jgiven</groupId> + <artifactId>jgiven-maven-plugin</artifactId> + <version>0.18.2</version> + <executions> + <execution> + <phase>post-integration-test</phase> + <goals> + <goal>report</goal> + </goals> + </execution> + </executions> + <configuration> + <format>html</format> + </configuration> + </plugin> </plugins> </build> diff --git a/saml/keystore.jks b/saml/keystore.jks deleted file mode 100644 index 402e7ab6eaa52384af67825a7d9bf9fd26183237..0000000000000000000000000000000000000000 Binary files a/saml/keystore.jks and /dev/null differ diff --git a/src/main/java/org/geant/lgservice/LookingGlassServiceApplication.java b/src/main/java/org/geant/lgservice/LookingGlassServiceApplication.java index 2d37ef6559f361750cfab987561033de78b46497..8ed4c25017169ceaa4cb6fbac1c77f6d41c56f67 100644 --- a/src/main/java/org/geant/lgservice/LookingGlassServiceApplication.java +++ b/src/main/java/org/geant/lgservice/LookingGlassServiceApplication.java @@ -1,32 +1,20 @@ package org.geant.lgservice; +import org.geant.lgservice.infrastructure.rest.inventoryprovider.InventoryProviderConfig; +import org.geant.lgservice.infrastructure.ssh.SSHConfig; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; -import springfox.documentation.builders.ApiInfoBuilder; -import springfox.documentation.builders.RequestHandlerSelectors; -import springfox.documentation.service.ApiInfo; -import springfox.documentation.spi.DocumentationType; -import springfox.documentation.spring.web.plugins.Docket; -import springfox.documentation.swagger2.annotations.EnableSwagger2; - -@EnableSwagger2 @SpringBootApplication +@Import({ + InventoryProviderConfig.class, + SSHConfig.class +}) public class LookingGlassServiceApplication { public static void main(String[] args) { SpringApplication.run(LookingGlassServiceApplication.class, args); } - @Bean - public Docket productApi() { - return new Docket(DocumentationType.SWAGGER_2).apiInfo(apiInfo()).select() - .apis(RequestHandlerSelectors.basePackage("org.geant.lgservice")).build(); - } - - private ApiInfo apiInfo() { - return new ApiInfoBuilder().title("Looking Glass Rest APIs") - .description("This page lists all the rest apis for Looking Glass Application.").build(); - } } diff --git a/src/main/java/org/geant/lgservice/config/AppConfig.java b/src/main/java/org/geant/lgservice/config/AppConfig.java index 147163082c60fe7499bfcd75c8a00d59f887db2f..254b7750442def27aa55563415c398666afe4377 100644 --- a/src/main/java/org/geant/lgservice/config/AppConfig.java +++ b/src/main/java/org/geant/lgservice/config/AppConfig.java @@ -30,18 +30,6 @@ public class AppConfig implements Serializable { @Value("${configuration.publickey.keyfile}") private String keyFile; - - @Value("${spring.datasource.username}") - private String dbUsername; - - @Value("${spring.datasource.password}") - private String dbPassword; - - @Value("${spring.datasource.url}") - private String dbURL; - - @Value("${spring.datasource.driverClassName}") - private String dbDriverClassName; @Value("${saml.metadata-url}") private String metadataURL; diff --git a/src/main/java/org/geant/lgservice/config/CommandsParser.java b/src/main/java/org/geant/lgservice/config/CommandsParser.java index 47d92ceab8fe70307bdb00b7bc47e6a60613ed47..1f8ee9526713d86b39af8ccafa8808d2717164b8 100644 --- a/src/main/java/org/geant/lgservice/config/CommandsParser.java +++ b/src/main/java/org/geant/lgservice/config/CommandsParser.java @@ -1,62 +1,61 @@ package org.geant.lgservice.config; -import java.io.InputStream; -import java.util.List; - -import javax.xml.bind.JAXBContext; -import javax.xml.bind.JAXBException; -import javax.xml.bind.Unmarshaller; - +import lombok.Getter; +import lombok.RequiredArgsConstructor; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.geant.lgservice.exceptions.TechnicalException; import org.geant.lgservice.pojos.Group; import org.geant.lgservice.security.LGUser; -import org.geant.lgservice.utils.LGRole; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import javax.xml.bind.JAXBContext; +import javax.xml.bind.JAXBException; +import javax.xml.bind.Unmarshaller; +import java.io.InputStream; +import java.util.List; +import java.util.Optional; public class CommandsParser { - private static final Logger logger = LogManager.getLogger(CommandsParser.class); - - public List<Group> getCommandsFromXML(final String fileName, final LGUser user) { - Commands commands = new Commands(); - try { - String newFileName = resolveFileName(fileName, user); - if (newFileName != null) { - ClassLoader classLoader = getClass().getClassLoader(); - InputStream stream = classLoader.getResourceAsStream(newFileName); - - JAXBContext jaxbContext = JAXBContext.newInstance(Commands.class); - Unmarshaller unmarshaller = jaxbContext.createUnmarshaller(); - commands = (Commands) unmarshaller.unmarshal(stream); - } else { - logger.error("Unable to read config file for commands list"); - } - } catch (JAXBException e) { - logger.error("Unable to parse xml for " + fileName, e); - } - - return commands.getGroups(); - } - - public String resolveFileName(final String fileName, final LGUser user) { - String newFileName = fileName + "_" + LGRole.PUBLIC.getName(); - if (user != null) { - switch (user.getRole()) { - case PUBLIC: { - newFileName = fileName + "_" + LGRole.PUBLIC.getName(); - break; - } - case INTERNAL: { - newFileName = fileName + "_" + LGRole.INTERNAL.getName(); - break; - } - case EXTERNAL: { - newFileName = fileName + "_" + LGRole.EXTERNAL.getName(); - break; - } - } - } - newFileName = newFileName + ".xml"; - return newFileName; - } + private static final Logger logger = LogManager.getLogger(CommandsParser.class); + + public List<Group> getCommandsFromXML(final String fileName, final LGUser user) { + try { + InputStream stream = getClass().getClassLoader().getResourceAsStream(resolveFileName(fileName, user)); + + JAXBContext jaxbContext = JAXBContext.newInstance(Commands.class); + Unmarshaller unmarshaller = jaxbContext.createUnmarshaller(); + return ((Commands) unmarshaller.unmarshal(stream)).getGroups(); + } catch (JAXBException e) { + logger.error("Unable to parse xml for " + fileName, e); + throw new TechnicalException(e); + } + + } + + @RequiredArgsConstructor + private enum GroupRanking { + INTERNAL(0), + EXTERNAL(1); + + @Getter + private final int rank; + } + + public String resolveFileName(final String fileName, final LGUser user) { + String accessLevel = Optional.ofNullable(user) + .map(UserDetails::getAuthorities) + .flatMap(authorities -> authorities + .stream() + .map(GrantedAuthority::getAuthority) + .map(GroupRanking::valueOf) + .reduce((groupOne, groupTwo) -> groupOne.getRank() < groupTwo.getRank() ? groupOne : groupTwo)) + .map(GroupRanking::name) + .map(String::toLowerCase) + .orElse("public"); + + return String.format("%s_%s.xml", fileName, accessLevel); + } } diff --git a/src/main/java/org/geant/lgservice/domain/Router.java b/src/main/java/org/geant/lgservice/domain/Router.java new file mode 100644 index 0000000000000000000000000000000000000000..5421521b49b14cfa705c7821d7a86f2dd6ea4dfc --- /dev/null +++ b/src/main/java/org/geant/lgservice/domain/Router.java @@ -0,0 +1,18 @@ +package org.geant.lgservice.domain; + +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@Builder +@RequiredArgsConstructor +public class Router { + private final String hostName; + private final Access access; + + public enum Access { + INTERNAL, + PUBLIC + } +} diff --git a/src/main/java/org/geant/lgservice/domain/RouterRepository.java b/src/main/java/org/geant/lgservice/domain/RouterRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..8177f0a98076a6d2e4b3d4d0ddccc4064e4ddb3f --- /dev/null +++ b/src/main/java/org/geant/lgservice/domain/RouterRepository.java @@ -0,0 +1,7 @@ +package org.geant.lgservice.domain; + +import java.util.Optional; + +public interface RouterRepository { + Optional<Router> getByHostName(String hostname); +} diff --git a/src/main/java/org/geant/lgservice/ecmr/CallableCommandExecutor.java b/src/main/java/org/geant/lgservice/ecmr/CallableCommandExecutor.java deleted file mode 100644 index afb29f3678965113917353734b39ee963b3b4929..0000000000000000000000000000000000000000 --- a/src/main/java/org/geant/lgservice/ecmr/CallableCommandExecutor.java +++ /dev/null @@ -1,122 +0,0 @@ -package org.geant.lgservice.ecmr; - -import java.io.BufferedReader; -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.util.concurrent.Callable; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import org.apache.commons.lang.StringUtils; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.geant.lgservice.pojos.Command; -import org.geant.lgservice.pojos.CommandOutput; -import org.geant.lgservice.pojos.Credentials; -import org.geant.lgservice.pojos.Router; - -import ch.ethz.ssh2.Connection; -import ch.ethz.ssh2.Session; - -public class CallableCommandExecutor implements Callable<CommandOutput> { - - private static final Logger logger = LogManager.getLogger(CallableCommandExecutor.class); - - private Router router; - - private Command command; - - private String arguments; - - private boolean displayAsXML; - - private Credentials credentials; - - CallableCommandExecutor(final Router router, final Command command, final String arguments, - final boolean displayAsXML, final Credentials credentials) { - this.router = router; - this.command = command; - this.arguments = arguments; - this.displayAsXML = displayAsXML; - this.credentials = credentials; - } - - @Override - public CommandOutput call() { - long start = System.currentTimeMillis(); - StringBuilder output = new StringBuilder(); - CommandOutput finalOutput = new CommandOutput(); - - Connection conn = new Connection(router.getName()); - try { - try { - conn.connect(null, 30000, 30000); // Should be configurable but this code is due a rewrite / pulled out - } catch (IOException e) { - logger.error("Error while connecting to router " + router.getName(), e); - return new CommandOutput(router.getName(), "Error while connecting to router " + router.getName(), null); - } - boolean isAuthenticated; - try { - isAuthenticated = conn.authenticateWithPublicKey(credentials.getUsername(), - new File(credentials.getKeyFile()), credentials.getPassword()); - } catch (IOException e) { - logger.error("Error while authenticating the user for router " + router.getName(), e); - return new CommandOutput(router.getName(), - "Error while authenticating the user for router " + router.getName(), null); - } - - if (isAuthenticated) { - Session session; - try { - session = conn.openSession(); - } catch (IOException e) { - logger.error("Error while opening session to router " + router.getName(), e); - return new CommandOutput(router.getName(), "Error while opening session to router " + router.getName(), - null); - } - InputStream in = session.getStdout(); - BufferedReader br = new BufferedReader(new InputStreamReader(in)); - - try { - session.execCommand(getCommandWithArguments()); - } catch (IOException e) { - logger.error("Error while executing command " + command.getValue(), e); - return new CommandOutput(router.getName(), "Error while executing command " + command.getValue(), null); - } - - while (true) { - String line = ""; - try { - line = br.readLine(); - if (line == null) - break; - output.append(line + "\n"); - } catch (IOException e) { - return new CommandOutput(router.getName(), "Error while reading line", null); - } - } - long finish = System.currentTimeMillis(); - finalOutput.setRouterName(router.getName()); - finalOutput.setCommandResult(output.toString()); - finalOutput.setExecutionTime(finish - start); - - return finalOutput; - } - return new CommandOutput(router.getName(), "Unable to authenticate user for router " + router.getName(), null); - } finally { - conn.close(); - } - } - - public String getCommandWithArguments() { - return Stream.of( - this.command.getValue(), - this.arguments, - this.displayAsXML ? "| display xml" : "") - .filter(StringUtils::isNotBlank) - .collect(Collectors.joining(" ")); - } - -} diff --git a/src/main/java/org/geant/lgservice/ecmr/CommandExecutor.java b/src/main/java/org/geant/lgservice/ecmr/CommandExecutor.java deleted file mode 100644 index 551969153187b00044026905fbd4b717302e3668..0000000000000000000000000000000000000000 --- a/src/main/java/org/geant/lgservice/ecmr/CommandExecutor.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.geant.lgservice.ecmr; - -import org.geant.lgservice.config.AppConfig; -import org.geant.lgservice.pojos.Command; -import org.geant.lgservice.pojos.CommandOutput; -import org.geant.lgservice.pojos.Credentials; -import org.geant.lgservice.pojos.Router; -import org.springframework.stereotype.Component; - -@Component -public class CommandExecutor { - - private final Credentials credentials; - - public CommandExecutor(AppConfig config) { - credentials = Credentials.builder() - .username(config.getUsername()) - .password(config.getPassword()) - .keyFile(config.getKeyFile()) - .build(); - } - - public CommandOutput execute(Router router, Command command, String arguments, boolean asXml) { - return new CallableCommandExecutor(router, command, arguments, asXml, credentials).call(); - } -} diff --git a/src/main/java/org/geant/lgservice/exceptions/BusinessException.java b/src/main/java/org/geant/lgservice/exceptions/BusinessException.java new file mode 100644 index 0000000000000000000000000000000000000000..68926f80859f45b5670daff8afb1f0e0b4729897 --- /dev/null +++ b/src/main/java/org/geant/lgservice/exceptions/BusinessException.java @@ -0,0 +1,33 @@ +package org.geant.lgservice.exceptions; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import static lombok.AccessLevel.PRIVATE; + +@RequiredArgsConstructor(access = PRIVATE) +public class BusinessException extends RuntimeException { + + @Getter + private final Reason reason; + + @AllArgsConstructor + public enum Reason { + UNAUTHORIZED("User is not logged in"), + FORBIDDEN("User does not have access rights"), + NOT_FOUND("Could not find router"); + + @Getter + private final String description; + } + + public static BusinessException withReason(Reason reason) { + return new BusinessException(reason); + } + + @Override + public String getMessage() { + return reason.getDescription(); + } +} diff --git a/src/main/java/org/geant/lgservice/exceptions/TechnicalException.java b/src/main/java/org/geant/lgservice/exceptions/TechnicalException.java new file mode 100644 index 0000000000000000000000000000000000000000..e18b63ae5faa0b9fbbfd2422f72fb3d68042ec93 --- /dev/null +++ b/src/main/java/org/geant/lgservice/exceptions/TechnicalException.java @@ -0,0 +1,11 @@ +package org.geant.lgservice.exceptions; + +public class TechnicalException extends RuntimeException { + public TechnicalException(Throwable t) { + super(t); + } + + public TechnicalException(String message) { + super(message); + } +} diff --git a/src/main/java/org/geant/lgservice/infrastructure/rest/inventoryprovider/InventoryProviderConfig.java b/src/main/java/org/geant/lgservice/infrastructure/rest/inventoryprovider/InventoryProviderConfig.java new file mode 100644 index 0000000000000000000000000000000000000000..81601df36f310ae51232bf8e3f27aee2c1bf3d59 --- /dev/null +++ b/src/main/java/org/geant/lgservice/infrastructure/rest/inventoryprovider/InventoryProviderConfig.java @@ -0,0 +1,39 @@ +package org.geant.lgservice.infrastructure.rest.inventoryprovider; + +import lombok.extern.slf4j.Slf4j; +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.geant.lgservice.domain.RouterRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.io.IOException; + +@Configuration +public class InventoryProviderConfig { + + @Bean + public RouterRepository routerRepository(@Value("${inventoryprovider.baseUrl}")String baseUrl) { + return InventoryProviderRouterRepository.builder().baseUrl(baseUrl) + .client(new OkHttpClient.Builder().addInterceptor(new OkHttpLogger()).build()).build(); + } + + @Slf4j + private static class OkHttpLogger implements Interceptor { + + @Override + public Response intercept(Chain chain) throws IOException { + + Request request = chain.request(); + log.info("Making request to {} with headers {}", request.url().toString(), request.headers()); + Response response = chain.proceed(request); + long contentLength = response.body().contentLength(); + log.info("Response recieved with status {} and body {}", response.code(), response.peekBody(contentLength >= 0 ? contentLength : Long.MAX_VALUE).string()); + return response; + } + } + +} diff --git a/src/main/java/org/geant/lgservice/infrastructure/rest/inventoryprovider/InventoryProviderRouterRepository.java b/src/main/java/org/geant/lgservice/infrastructure/rest/inventoryprovider/InventoryProviderRouterRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..17ac48d40a7878533f62cffb126e8d02c7bebc28 --- /dev/null +++ b/src/main/java/org/geant/lgservice/infrastructure/rest/inventoryprovider/InventoryProviderRouterRepository.java @@ -0,0 +1,124 @@ +package org.geant.lgservice.infrastructure.rest.inventoryprovider; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.collect.ImmutableMap; +import lombok.Builder; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import org.geant.lgservice.domain.Router; +import org.geant.lgservice.domain.RouterRepository; +import org.geant.lgservice.exceptions.TechnicalException; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.HttpStatusCodeException; +import retrofit2.Call; +import retrofit2.Response; +import retrofit2.Retrofit; +import retrofit2.converter.jackson.JacksonConverterFactory; +import retrofit2.http.GET; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static java.nio.charset.Charset.defaultCharset; +import static org.geant.lgservice.domain.Router.Access.INTERNAL; +import static org.geant.lgservice.domain.Router.Access.PUBLIC; + +@Slf4j +public class InventoryProviderRouterRepository implements RouterRepository { + + private final InventoryProvider inventoryProvider; + + @Override + public Optional<Router> getByHostName(String hostname) { + try { + Response<Collection<InventoryProvider.InventoryProviderRouter>> response = inventoryProvider.getRouters() + .execute(); + if (!response.isSuccessful()) { + HttpStatus status = HttpStatus.valueOf(response.code()); + HttpStatusCodeException rootException = status.is4xxClientError() ? + new HttpClientErrorException(status, response.message(), response.errorBody() + .bytes(), defaultCharset()) : + new HttpServerErrorException(status, response.message(), response.errorBody() + .bytes(), defaultCharset()); + log.error(response.message(), rootException); + throw new TechnicalException(rootException); + } + + return response.body() + .stream() + .filter(router -> router.getEquipmentName().equals(hostname)) + .map(InventoryProvider.InventoryProviderRouter::toDomain) + .findFirst(); + } catch (IOException e) { + throw new TechnicalException(e); + } + } + + @Builder + public InventoryProviderRouterRepository(String baseUrl, OkHttpClient client) { + OkHttpClient.Builder builder = + (client != null ? client.newBuilder() : new OkHttpClient.Builder()); + + List<Interceptor> existingInterceptors = builder.interceptors(); + List<Interceptor> interceptors = Stream.concat( + Stream.of((Interceptor.Chain chain) -> chain.proceed(chain.request().newBuilder() + .addHeader("Accept", "application/json") + .build())), + existingInterceptors.stream()).collect(Collectors.toList()); + + existingInterceptors.removeIf(interceptors::contains); + existingInterceptors.addAll(interceptors); + + this.inventoryProvider = new Retrofit.Builder() + .baseUrl(baseUrl) + .client(builder.build()) + .addConverterFactory(JacksonConverterFactory.create()) + .build() + .create(InventoryProvider.class); + } + + interface InventoryProvider { + @GET("routers/all") + Call<Collection<InventoryProviderRouter>> getRouters(); + + @Getter + @JsonIgnoreProperties(ignoreUnknown = true) + class InventoryProviderRouter { + private final String equipmentName; + private final String type; + + @JsonCreator + public InventoryProviderRouter( + @JsonProperty("equipment name") String equipmentName, + @JsonProperty("type") String type + ) { + this.equipmentName = equipmentName; + this.type = type; + } + + private static final Map<String, Router.Access> ACCESS_MAPPINGS = + ImmutableMap.<String, Router.Access>builder() + .put("INTERNAL", INTERNAL) + .build(); + + public Router toDomain() { + return Router.builder() + .hostName(equipmentName) + .access(ACCESS_MAPPINGS.getOrDefault(type, PUBLIC)) + .build(); + } + } + + } +} diff --git a/src/main/java/org/geant/lgservice/infrastructure/ssh/ConnectionFactory.java b/src/main/java/org/geant/lgservice/infrastructure/ssh/ConnectionFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..838cadc104ae218bcc523e36640f832a49e4b35a --- /dev/null +++ b/src/main/java/org/geant/lgservice/infrastructure/ssh/ConnectionFactory.java @@ -0,0 +1,36 @@ +package org.geant.lgservice.infrastructure.ssh; + +import ch.ethz.ssh2.Connection; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.geant.lgservice.exceptions.TechnicalException; + +import java.io.File; +import java.io.IOException; + +import static java.lang.String.format; + +@RequiredArgsConstructor +@Slf4j +public class ConnectionFactory { + + private final String username; + private final String password; + private final File keyFile; + private final int timeout; + + public Connection createConnection(String hostname) { + log.info("Creating connection for {}", hostname); + Connection connection = new Connection(hostname); + try { + connection.connect(null, timeout, timeout); + if (!connection.authenticateWithPublicKey(username, keyFile, password)){ + throw new TechnicalException(format("Could not authenticate ssh connection with %s", hostname)); + } + } catch (IOException e) { + throw new TechnicalException(e); + } + log.info("Connection for {} created", hostname); + return connection; + } +} diff --git a/src/main/java/org/geant/lgservice/infrastructure/ssh/ConnectionManager.java b/src/main/java/org/geant/lgservice/infrastructure/ssh/ConnectionManager.java new file mode 100644 index 0000000000000000000000000000000000000000..17f3249b21630c9213320fd606a08bf5db365fe7 --- /dev/null +++ b/src/main/java/org/geant/lgservice/infrastructure/ssh/ConnectionManager.java @@ -0,0 +1,46 @@ +package org.geant.lgservice.infrastructure.ssh; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import static org.geant.lgservice.infrastructure.ssh.SSHHost.ConnectionStateListener.State.DISCONNECTED; +import static org.geant.lgservice.infrastructure.ssh.SSHHost.ConnectionStateListener.State.ERROR; + +@Slf4j +@Component +@AllArgsConstructor +public class ConnectionManager implements SSHHost.ConnectionStateListener { + + private final ConnectionFactory connectionFactory; + + private final Map<String, SSHHost> hosts = new ConcurrentHashMap<>(); + + public SSHHost getHost(String hostname) { + log.info("retrieving host {}", hostname); + synchronized (this) { + return hosts + .computeIfAbsent( + hostname, + host -> { + log.info("Host [{}] connection does not exist", hostname); + return SSHHost.fromConnection(connectionFactory.createConnection(host)) + .withConnectionStateListener(this); + }); + } + } + + @Override + public void stateChanged(SSHHost host, State state) { + synchronized (this) { + log.info("host [{}] state changed to [{}]", host, state); + if (state.equals(DISCONNECTED) || state.equals(ERROR)) { + hosts.remove(host); + } + log.info("currently active connections: [{}]", hosts.keySet()); + } + } +} diff --git a/src/main/java/org/geant/lgservice/infrastructure/ssh/SSHCommandExecutor.java b/src/main/java/org/geant/lgservice/infrastructure/ssh/SSHCommandExecutor.java new file mode 100644 index 0000000000000000000000000000000000000000..35f532db31c73edc1ef157da219d7616114f8095 --- /dev/null +++ b/src/main/java/org/geant/lgservice/infrastructure/ssh/SSHCommandExecutor.java @@ -0,0 +1,31 @@ +package org.geant.lgservice.infrastructure.ssh; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.geant.lgservice.pojos.CommandOutput; +import org.springframework.stereotype.Component; +import org.springframework.util.StopWatch; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SSHCommandExecutor { + + private final ConnectionManager connectionManager; + + public CommandOutput execute(String hostname, String command) { + log.info("Executing command [{}] on host [{}]", command, hostname); + StopWatch timer = new StopWatch(); + timer.start(); + String response = connectionManager.getHost(hostname) + .execute(command); + timer.stop(); + log.info("Command completed on host [{}] in [{}]ms with result [{}]", hostname, timer.getTotalTimeMillis(), response); + return CommandOutput.builder() + .routerName(hostname) + .commandResult(response) + .executionTime(timer.getTotalTimeMillis()) + .build(); + } + +} diff --git a/src/main/java/org/geant/lgservice/infrastructure/ssh/SSHConfig.java b/src/main/java/org/geant/lgservice/infrastructure/ssh/SSHConfig.java new file mode 100644 index 0000000000000000000000000000000000000000..eb195bfd2a62db05ccab15b87e67ab81456dee63 --- /dev/null +++ b/src/main/java/org/geant/lgservice/infrastructure/ssh/SSHConfig.java @@ -0,0 +1,22 @@ +package org.geant.lgservice.infrastructure.ssh; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.io.File; + +@Configuration +public class SSHConfig { + + @Bean + public ConnectionFactory connectionFactory( + @Value("${configuration.publickey.username}") String username, + @Value("${configuration.publickey.keyfile}") File keyFile, + @Value("${configuration.publickey.password}") String password, + @Value("${configuration.ssh.connection.timeout:30000}") int timeout + ) { + return new ConnectionFactory(username, password, keyFile, timeout); + } + +} diff --git a/src/main/java/org/geant/lgservice/infrastructure/ssh/SSHHost.java b/src/main/java/org/geant/lgservice/infrastructure/ssh/SSHHost.java new file mode 100644 index 0000000000000000000000000000000000000000..98d7d29f0622cc5567c6671e31329d50f04f5099 --- /dev/null +++ b/src/main/java/org/geant/lgservice/infrastructure/ssh/SSHHost.java @@ -0,0 +1,94 @@ +package org.geant.lgservice.infrastructure.ssh; + +import ch.ethz.ssh2.Connection; +import ch.ethz.ssh2.ConnectionMonitor; +import ch.ethz.ssh2.Session; +import lombok.extern.slf4j.Slf4j; +import org.geant.lgservice.exceptions.TechnicalException; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.Collection; +import java.util.HashSet; +import java.util.Optional; +import java.util.stream.Collectors; + +import static org.geant.lgservice.infrastructure.ssh.SSHHost.ConnectionStateListener.State.DISCONNECTED; +import static org.geant.lgservice.infrastructure.ssh.SSHHost.ConnectionStateListener.State.ERROR; + +@Slf4j +public class SSHHost implements ConnectionMonitor { + + private final Connection connection; + private final Collection<ConnectionStateListener> connectionStateListeners = new HashSet<>(); + + private SSHHost(Connection connection) { + this.connection = connection; + connection.addConnectionMonitor(this); + } + + public static SSHHost fromConnection(Connection connection) { + if (!connection.isAuthenticationComplete()) { + throw new TechnicalException("Connection is not authenticated"); + } + return new SSHHost(connection); + } + + public String execute(String command) { + Session session = null; + try { + log.debug("opening session on connection [{}] with host [{}]", connection, hostname()); + session = connection.openSession(); + log.debug("Session [{}] opened", session); + + log.debug("sending command [{}] to host [{}]", command, hostname()); + session.execCommand(command); + log.debug("command [{}] sent to host [{}]", command, hostname()); + + int exitStatus = Optional.of(session).map(Session::getExitStatus).orElse(0); + log.debug("Exit status [{}] recieved", exitStatus); + + InputStream response = exitStatus != 0 ? session.getStderr() : session.getStdout(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(response))) { + return reader.lines().collect(Collectors.joining("\n")); + } + } catch (IOException e) { + triggerStateChangeListeners(ERROR); + connection.close(); + throw new TechnicalException(e); + } + } + + public String hostname() { + return connection.getHostname(); + } + + public SSHHost withConnectionStateListener(ConnectionStateListener listener) { + connectionStateListeners.add(listener); + return this; + } + + private void triggerStateChangeListeners(ConnectionStateListener.State state) { + connectionStateListeners + .forEach(connectionStateListener -> connectionStateListener.stateChanged(this, state)); + } + + @Override + public void connectionLost(Throwable reason) { + log.warn("Host [{}] lost connection: ", connection.getHostname(), reason); + triggerStateChangeListeners(DISCONNECTED); + } + + @FunctionalInterface + public interface ConnectionStateListener { + + enum State { + DISCONNECTED, + ERROR + } + + void stateChanged(SSHHost host, State state); + } +} diff --git a/src/main/java/org/geant/lgservice/interfaces/rest/BusinessExceptionHandler.java b/src/main/java/org/geant/lgservice/interfaces/rest/BusinessExceptionHandler.java new file mode 100644 index 0000000000000000000000000000000000000000..07fb9acb6696f814d69616b33ffbe63ca2f36836 --- /dev/null +++ b/src/main/java/org/geant/lgservice/interfaces/rest/BusinessExceptionHandler.java @@ -0,0 +1,20 @@ +package org.geant.lgservice.interfaces.rest; + +import lombok.extern.slf4j.Slf4j; +import org.geant.lgservice.exceptions.BusinessException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +@Slf4j +@ControllerAdvice +public class BusinessExceptionHandler extends ResponseEntityExceptionHandler { + + @ExceptionHandler(BusinessException.class) + protected ResponseEntity<Object> handleBusinessException (BusinessException ex) { + log.info("Business error: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.valueOf(ex.getReason().name())).build(); + } +} diff --git a/src/main/java/org/geant/lgservice/interfaces/rest/TechnicalExceptionHandler.java b/src/main/java/org/geant/lgservice/interfaces/rest/TechnicalExceptionHandler.java new file mode 100644 index 0000000000000000000000000000000000000000..bf184cc96a76572005d456cd6f4c84dd2fed0925 --- /dev/null +++ b/src/main/java/org/geant/lgservice/interfaces/rest/TechnicalExceptionHandler.java @@ -0,0 +1,21 @@ +package org.geant.lgservice.interfaces.rest; + +import lombok.extern.slf4j.Slf4j; +import org.geant.lgservice.exceptions.TechnicalException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; + +@ControllerAdvice +@Slf4j +public class TechnicalExceptionHandler extends ResponseEntityExceptionHandler { + + @ExceptionHandler(TechnicalException.class) + protected ResponseEntity<Object> handleBusinessException(TechnicalException ex) { + log.error("Caught Technical Exception: ", ex); + return ResponseEntity.status(INTERNAL_SERVER_ERROR).build(); + } +} diff --git a/src/main/java/org/geant/lgservice/model/Equipment.java b/src/main/java/org/geant/lgservice/model/Equipment.java deleted file mode 100755 index c08a8156497a528270cac3747c6a7b3154c0baaf..0000000000000000000000000000000000000000 --- a/src/main/java/org/geant/lgservice/model/Equipment.java +++ /dev/null @@ -1,67 +0,0 @@ -package org.geant.lgservice.model; - -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.Id; -import javax.persistence.Table; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.geant.lgservice.pojos.Coordinates; -import org.geant.lgservice.pojos.Router; - -@Data -@Entity() -@NoArgsConstructor -@AllArgsConstructor -@Table(name = "vlg_routers") -public class Equipment { - - @Id - @Column(name = "router_absid") - private int routerAbsid; - - @Column(name = "router_name") - private String routerName; - - @Column(name = "pop_absid") - private int popAbsid; - - @Column(name = "pop_name") - private String popName; - - @Column(name = "pop_lat") - private String popLat; - - @Column(name = "pop_long") - private String popLong; - - @Column(name = "pop_country_code") - private String popCountryCode; - - @Column(name = "pop_country") - private String popCountry; - - @Column(name = "pop_city") - private String popCity; - - @Column(name = "internal") - private String internal; - - @Column(name = "pop_abbrev") - private String popAbbrev; - - public Router toDomain() { - return Router.builder() - .abbreviatedName(popAbbrev) - .city(popCity) - .coordinates(new Coordinates(popLat, popLong)) - .name(routerName) - .popCountryCode(popCountryCode) - .country(popCountry) - .popName(popName) - .build(); - } - -} diff --git a/src/main/java/org/geant/lgservice/pojos/Command.java b/src/main/java/org/geant/lgservice/pojos/Command.java index 674fdc65cd166998a69fa5b080e03bd89c1702aa..d6f806f2a0d077cc887b5c16070d8406d05ab566 100644 --- a/src/main/java/org/geant/lgservice/pojos/Command.java +++ b/src/main/java/org/geant/lgservice/pojos/Command.java @@ -1,12 +1,12 @@ package org.geant.lgservice.pojos; -import lombok.AllArgsConstructor; -import lombok.Data; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; -@Data @NoArgsConstructor -@AllArgsConstructor +@Setter +@Getter public class Command { private String name; diff --git a/src/main/java/org/geant/lgservice/pojos/CommandOutput.java b/src/main/java/org/geant/lgservice/pojos/CommandOutput.java index 89b252988870a6a51b3384ac0649e1c60fe29352..c4073ba2c1dc43f891e4968721e9cbfd2d215273 100644 --- a/src/main/java/org/geant/lgservice/pojos/CommandOutput.java +++ b/src/main/java/org/geant/lgservice/pojos/CommandOutput.java @@ -1,16 +1,14 @@ package org.geant.lgservice.pojos; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.Builder; +import lombok.Getter; -@Data -@NoArgsConstructor -@AllArgsConstructor +@Getter +@Builder public class CommandOutput { - private String routerName; - private String commandResult; - private Long executionTime; + private final String routerName; + private final String commandResult; + private final Long executionTime; } diff --git a/src/main/java/org/geant/lgservice/pojos/Coordinates.java b/src/main/java/org/geant/lgservice/pojos/Coordinates.java deleted file mode 100644 index 622c71d4ad288808db47f9f768b50c39291c4bf4..0000000000000000000000000000000000000000 --- a/src/main/java/org/geant/lgservice/pojos/Coordinates.java +++ /dev/null @@ -1,39 +0,0 @@ -package org.geant.lgservice.pojos; - -import javax.xml.bind.annotation.XmlAttribute; - -public class Coordinates { - - private String latitude; - - private String longitude; - - public Coordinates() { - - } - - public Coordinates(String latitude, String longitude) { - super(); - this.latitude = latitude; - this.longitude = longitude; - } - - public String getLatitude() { - return latitude; - } - - @XmlAttribute(name = "lat") - public void setLatitude(String latitude) { - this.latitude = latitude; - } - - public String getLongitude() { - return longitude; - } - - @XmlAttribute(name = "long") - public void setLongitude(String longitude) { - this.longitude = longitude; - } - -} diff --git a/src/main/java/org/geant/lgservice/pojos/Credentials.java b/src/main/java/org/geant/lgservice/pojos/Credentials.java deleted file mode 100644 index 05f6defe925b12f3bff7d4e19a171609e14f2615..0000000000000000000000000000000000000000 --- a/src/main/java/org/geant/lgservice/pojos/Credentials.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.geant.lgservice.pojos; - -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class Credentials { - - private String username; - - private String password; - - private String keyFile; - -} diff --git a/src/main/java/org/geant/lgservice/pojos/QueryInputRequest.java b/src/main/java/org/geant/lgservice/pojos/QueryInputRequest.java index 6ff035763a9c62acfc716e5198fd7a7218ccbc50..3f9b2abf1c66052a151a5867a61662568d0c7197 100644 --- a/src/main/java/org/geant/lgservice/pojos/QueryInputRequest.java +++ b/src/main/java/org/geant/lgservice/pojos/QueryInputRequest.java @@ -1,13 +1,11 @@ package org.geant.lgservice.pojos; -import java.util.List; - -import lombok.AllArgsConstructor; -import lombok.Data; +import lombok.Getter; import lombok.NoArgsConstructor; -@Data -@AllArgsConstructor +import java.util.List; + +@Getter @NoArgsConstructor public class QueryInputRequest { diff --git a/src/main/java/org/geant/lgservice/pojos/QueryResponse.java b/src/main/java/org/geant/lgservice/pojos/QueryResponse.java index 9ded0504bae9655840e85312b76ff20c5027090b..4d6584d66232ec7b864d18d7305c8b3fddb5feea 100644 --- a/src/main/java/org/geant/lgservice/pojos/QueryResponse.java +++ b/src/main/java/org/geant/lgservice/pojos/QueryResponse.java @@ -1,14 +1,12 @@ package org.geant.lgservice.pojos; -import java.util.Map; +import lombok.Builder; +import lombok.Getter; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; +import java.util.Map; -@Data -@NoArgsConstructor -@AllArgsConstructor +@Getter +@Builder public class QueryResponse { Map<String, CommandOutput> output; diff --git a/src/main/java/org/geant/lgservice/pojos/Router.java b/src/main/java/org/geant/lgservice/pojos/Router.java index 19ee044a55f41bd3d4fae90c1987661792252322..6975c43f3c1cb2593c5989053f93bbe020410324 100644 --- a/src/main/java/org/geant/lgservice/pojos/Router.java +++ b/src/main/java/org/geant/lgservice/pojos/Router.java @@ -1,32 +1,10 @@ package org.geant.lgservice.pojos; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.Getter; -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder +@Getter public class Router { - private Coordinates coordinates; - private String name; - private String authType; - - private String city; - - private String country; - - private String popName; - - private String popCountryCode; - - private boolean selected; - - private String abbreviatedName; - } diff --git a/src/main/java/org/geant/lgservice/repository/EquipmentRepository.java b/src/main/java/org/geant/lgservice/repository/EquipmentRepository.java deleted file mode 100755 index 058a2c68b85668fd86eb4e58e271dc59ab7546eb..0000000000000000000000000000000000000000 --- a/src/main/java/org/geant/lgservice/repository/EquipmentRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package org.geant.lgservice.repository; - -import org.geant.lgservice.model.Equipment; -import org.springframework.data.repository.CrudRepository; -import org.springframework.stereotype.Repository; - -@Repository -public interface EquipmentRepository extends CrudRepository<Equipment, Integer> { - -} diff --git a/src/main/java/org/geant/lgservice/rest/LookingGlassRestController.java b/src/main/java/org/geant/lgservice/rest/LookingGlassRestController.java index 8e86c0a643330ecacca6bfd1e12885e1a4351281..39b98d64aeec92340a0d03a9cc779b5dc6a00dbb 100644 --- a/src/main/java/org/geant/lgservice/rest/LookingGlassRestController.java +++ b/src/main/java/org/geant/lgservice/rest/LookingGlassRestController.java @@ -1,22 +1,18 @@ package org.geant.lgservice.rest; -import java.util.List; -import java.util.Map; - import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.geant.lgservice.exceptions.ConnectionException; import org.geant.lgservice.pojos.CommandOutput; import org.geant.lgservice.pojos.Group; import org.geant.lgservice.pojos.QueryInputRequest; import org.geant.lgservice.pojos.QueryResponse; -import org.geant.lgservice.pojos.Router; import org.geant.lgservice.security.LGUser; import org.geant.lgservice.services.BuildService; import org.geant.lgservice.services.LookingGlassService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.info.BuildProperties; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.GetMapping; @@ -25,7 +21,10 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import io.swagger.annotations.ApiOperation; +import java.util.List; +import java.util.Map; + +import static java.util.stream.Collectors.toMap; @RestController @RequestMapping("/rest") @@ -51,17 +50,6 @@ public class LookingGlassRestController { return ResponseEntity.ok(commands); } - @GetMapping(value = "/routers/all") - public ResponseEntity<?> getAllRouters() { - LGUser currentUser = null; - Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - if (principal instanceof LGUser) { - currentUser = (LGUser) principal; - } - List<Router> routers = service.getAllRouters(currentUser); - return ResponseEntity.ok(routers); - } - @GetMapping(value = "/user/getLoggedInUser") public ResponseEntity<?> getLoggedInUser() { Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); @@ -75,19 +63,16 @@ public class LookingGlassRestController { @PostMapping(value = "/submit") @CrossOrigin - public ResponseEntity<QueryResponse> submit(@RequestBody QueryInputRequest input) throws ConnectionException { - QueryResponse response = new QueryResponse(); - Map<String, CommandOutput> result = service.submitQuery(input.getSelectedRouters(), input.getSelectedCommand(), - input.getArguments(), input.isDisplayAsXML()); - if (result != null && !result.isEmpty()) - response.setOutput(result); - else - throw new ConnectionException(); - return ResponseEntity.ok(response); + public QueryResponse submit(@RequestBody QueryInputRequest input, @AuthenticationPrincipal LGUser user) { + + Map<String, CommandOutput> result = input.getSelectedRouters().stream() + .map(router -> service.submitQuery(router.getName(), input.getSelectedCommand(), input.getArguments(), input.isDisplayAsXML(), user)) + .collect(toMap(CommandOutput::getRouterName, output -> output)); + + return QueryResponse.builder().output(result).build(); } @GetMapping(value = "/build") - @ApiOperation(value = "Get build information", response = String.class) public ResponseEntity<?> getBuildInfo() { BuildProperties buildProperties = buildService.getBuildInfo(); return ResponseEntity.ok(buildProperties); diff --git a/src/main/java/org/geant/lgservice/security/LGUser.java b/src/main/java/org/geant/lgservice/security/LGUser.java index 54859a3faf55f4e499e8c96e380d26575a5940cd..4aa362ed5a388b9a2060e2203bb54ad35c542b31 100644 --- a/src/main/java/org/geant/lgservice/security/LGUser.java +++ b/src/main/java/org/geant/lgservice/security/LGUser.java @@ -1,103 +1,69 @@ package org.geant.lgservice.security; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; - -import org.geant.lgservice.utils.LGRole; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Singular; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.userdetails.User; - -public class LGUser extends User { - - private static final long serialVersionUID = 1L; - - private String federatedUser; - private String givenName; - private String surname; - private String mail; - private LGRole role; - private Collection<? extends GrantedAuthority> groups; - - public static final String INTERNAL_GROUP = "GEANT Staff:All"; - public static final String EXTERNAL_GROUP = "GEANT_CO:GEANT Services:NREN Svc Mgmt:GEANT NRENs:members_GEANT NRENs"; - - public LGUser(String federatedUser, String givenName, String surname, String mail, - Collection<? extends GrantedAuthority> groups) { - - super(federatedUser, "DUMMY PASSWORD", true, true, true, true, groups); - - this.federatedUser = federatedUser; - this.givenName = givenName; - this.surname = surname; - this.mail = mail; - this.groups = groups; - - List<String> groupValues = new ArrayList<>(); - for (GrantedAuthority grantedAuthority : groups) { - groupValues.add(grantedAuthority.getAuthority()); - } - - if (!groupValues.contains(INTERNAL_GROUP) && !groupValues.contains(EXTERNAL_GROUP)) { - this.role = LGRole.PUBLIC; - } - - if (groupValues.contains(INTERNAL_GROUP)) { - this.role = LGRole.INTERNAL; - } - - if (groupValues.contains(EXTERNAL_GROUP)) { - this.role = LGRole.EXTERNAL; - } +import org.springframework.security.core.userdetails.UserDetails; - } - - public String getSurname() { - return surname; - } - - public void setSurname(String surname) { - this.surname = surname; - } - - public String getGivenName() { - return givenName; - } - - public void setGivenName(String givenName) { - this.givenName = givenName; - } - - public String getMail() { - return mail; - } - - public void setMail(String mail) { - this.mail = mail; - } - - public String getFederatedUser() { - return federatedUser; - } - - public void setFederatedUser(String federatedUser) { - this.federatedUser = federatedUser; - } - - public Collection<? extends GrantedAuthority> getGroups() { - return groups; - } - - public void setGroups(Collection<? extends GrantedAuthority> groups) { - this.groups = groups; - } - - public LGRole getRole() { - return role; - } +import java.util.Collection; - public void setRole(LGRole role) { - this.role = role; - } +@Builder +@Getter +public class LGUser implements UserDetails { + + private final String federatedUser; + private final String givenName; + private final String surname; + private final String mail; + @Singular + private final Collection<Group> groups; + + @RequiredArgsConstructor + public enum Group implements GrantedAuthority{ + INTERNAL, + EXTERNAL; + + @Override + public String getAuthority() { + return name(); + } + } + + @Override + public Collection<? extends GrantedAuthority> getAuthorities() { + return getGroups(); + } + + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return federatedUser; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } } diff --git a/src/main/java/org/geant/lgservice/security/LGUserService.java b/src/main/java/org/geant/lgservice/security/LGUserService.java index 506512f993831a945769920196dac7303b1cfe35..6949949ab3c245652e87b76e2136cff59bc10c38 100644 --- a/src/main/java/org/geant/lgservice/security/LGUserService.java +++ b/src/main/java/org/geant/lgservice/security/LGUserService.java @@ -1,39 +1,55 @@ package org.geant.lgservice.security; -import java.util.ArrayList; -import java.util.List; - +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.saml.SAMLCredential; import org.springframework.security.saml.userdetails.SAMLUserDetailsService; import org.springframework.stereotype.Service; +import java.util.Arrays; +import java.util.Collection; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +import static org.geant.lgservice.security.SamlKeyMappings.Key.EMAIL; +import static org.geant.lgservice.security.SamlKeyMappings.Key.GIVEN_NAME; +import static org.geant.lgservice.security.SamlKeyMappings.Key.GROUPS; +import static org.geant.lgservice.security.SamlKeyMappings.Key.ID; +import static org.geant.lgservice.security.SamlKeyMappings.Key.SURNAME; + @Service +@RequiredArgsConstructor +@Slf4j public class LGUserService implements SAMLUserDetailsService { - private static final Logger LOGGER = LoggerFactory.getLogger(WebSecurityConfig.class); - - @Override - public Object loadUserBySAML(SAMLCredential credential) throws UsernameNotFoundException { - String federatedUser = credential.getAttributeAsString("uid"); - String givenName = credential.getAttributeAsString("givenName"); - String surname = credential.getAttributeAsString("sn"); - String mail = credential.getAttributeAsString("mail"); - - List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>(); - String[] groupNames = credential.getAttributeAsStringArray("isMemberOf"); - if (groupNames != null) { - for (String role : groupNames) { - GrantedAuthority authority = new SimpleGrantedAuthority(role); - authorities.add(authority); - } - } - LOGGER.info("Current user : " + givenName + ", " + surname); - LGUser currentUser = new LGUser(federatedUser, givenName, surname, mail, authorities); - return currentUser; - } + private final Map<SamlKeyMappings.Key, String> keyMappings; + private final Map<String, LGUser.Group> groupMappings; + + @Override + public Object loadUserBySAML(SAMLCredential credential) throws UsernameNotFoundException { + log.debug("Extracting attributes using keys: {}", keyMappings); + + String federatedUser = credential.getAttributeAsString(keyMappings.get(ID)); + String givenName = credential.getAttributeAsString(keyMappings.get(GIVEN_NAME)); + String surname = credential.getAttributeAsString(keyMappings.get(SURNAME)); + String mail = credential.getAttributeAsString(keyMappings.get(EMAIL)); + + Collection<LGUser.Group> groups = Arrays.stream(credential.getAttributeAsStringArray(keyMappings.get(GROUPS))) + .map(groupMappings::get) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + log.info("Current user : " + givenName + ", " + surname); + return LGUser.builder() + .federatedUser(federatedUser) + .givenName(givenName) + .surname(surname) + .mail(mail) + .groups(groups) + .build(); + } } diff --git a/src/main/java/org/geant/lgservice/security/SamlGroupMapping.java b/src/main/java/org/geant/lgservice/security/SamlGroupMapping.java new file mode 100644 index 0000000000000000000000000000000000000000..4bdbd6ded72d096807f2f740d6ec74fe51e5e960 --- /dev/null +++ b/src/main/java/org/geant/lgservice/security/SamlGroupMapping.java @@ -0,0 +1,24 @@ +package org.geant.lgservice.security; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.ConstructorBinding; + +import java.util.Map; +import java.util.stream.Collectors; + +import static java.util.Collections.unmodifiableMap; + +@ConstructorBinding +@ConfigurationProperties("saml") +public class SamlGroupMapping { + private final Map<LGUser.Group, String> groupMappings; + + public SamlGroupMapping(Map<LGUser.Group, String> groupMappings) { + this.groupMappings = groupMappings; + } + + public Map<String, LGUser.Group> groupMappings() { + return unmodifiableMap(groupMappings.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getValue, Map.Entry::getKey))); + } +} diff --git a/src/main/java/org/geant/lgservice/security/SamlKeyMappings.java b/src/main/java/org/geant/lgservice/security/SamlKeyMappings.java new file mode 100644 index 0000000000000000000000000000000000000000..7c2551775e4800c7cd89ad6d90ff4bd1f953b64b --- /dev/null +++ b/src/main/java/org/geant/lgservice/security/SamlKeyMappings.java @@ -0,0 +1,38 @@ +package org.geant.lgservice.security; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.ConstructorBinding; + +import java.util.Arrays; +import java.util.Map; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static java.lang.String.format; +import static java.util.Collections.unmodifiableMap; + +@ConstructorBinding +@ConfigurationProperties("saml") +public class SamlKeyMappings { + + public enum Key { + ID, + GIVEN_NAME, + SURNAME, + EMAIL, + GROUPS + } + + private final Map<Key, String> keyMappings; + + public SamlKeyMappings(Map<Key, String> keyMappings) { + checkNotNull(keyMappings, "Missing saml key mappings, under 'saml.key-mappings'"); + checkArgument(keyMappings.size() == Key + .values().length, format("Missing saml key mappings, required: %s", Arrays.toString(Key.values()))); + this.keyMappings = keyMappings; + } + + public Map<Key, String> mappings() { + return unmodifiableMap(keyMappings); + } +} diff --git a/src/main/java/org/geant/lgservice/security/WebSecurityConfig.java b/src/main/java/org/geant/lgservice/security/WebSecurityConfig.java index 2f27ad859d0c82e997cdf4d6212a5f343706c5f9..349490dd5ea24c1088569d13f1d4bb2aacb4f953 100644 --- a/src/main/java/org/geant/lgservice/security/WebSecurityConfig.java +++ b/src/main/java/org/geant/lgservice/security/WebSecurityConfig.java @@ -11,6 +11,7 @@ import org.opensaml.xml.parse.StaticBasicParserPool; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.DefaultResourceLoader; @@ -71,65 +72,70 @@ import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import java.util.*; +import java.util.Timer; import static org.springframework.web.util.UriComponentsBuilder.fromHttpUrl; @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(securedEnabled = true) +@EnableConfigurationProperties({ + SamlGroupMapping.class, + SamlKeyMappings.class +}) public class WebSecurityConfig extends WebSecurityConfigurerAdapter { - - @Autowired - AppConfig config; - - private static final Logger LOGGER = LoggerFactory.getLogger(WebSecurityConfig.class); - - private String metadataURL; - - private Timer backgroundTaskTimer; - private MultiThreadedHttpConnectionManager multiThreadedHttpConnectionManager; - - @PostConstruct - public void init() { - this.backgroundTaskTimer = new Timer(true); - this.multiThreadedHttpConnectionManager = new MultiThreadedHttpConnectionManager(); - this.metadataURL = config.getMetadataURL(); - } - - @PreDestroy - public void destroy() { - this.backgroundTaskTimer.purge(); - this.backgroundTaskTimer.cancel(); - this.multiThreadedHttpConnectionManager.shutdown(); - } - + + @Autowired + AppConfig config; + + private static final Logger LOGGER = LoggerFactory.getLogger(WebSecurityConfig.class); + + private String metadataURL; + + private Timer backgroundTaskTimer; + private MultiThreadedHttpConnectionManager multiThreadedHttpConnectionManager; + + @PostConstruct + public void init() { + this.backgroundTaskTimer = new Timer(true); + this.multiThreadedHttpConnectionManager = new MultiThreadedHttpConnectionManager(); + this.metadataURL = config.getMetadataURL(); + } + + @PreDestroy + public void destroy() { + this.backgroundTaskTimer.purge(); + this.backgroundTaskTimer.cancel(); + this.multiThreadedHttpConnectionManager.shutdown(); + } + @Autowired private LGUserService userService; - + @Bean public VelocityEngine velocityEngine() { return VelocityFactory.getEngine(); } - + @Bean(initMethod = "initialize") public StaticBasicParserPool parserPool() { return new StaticBasicParserPool(); } - + @Bean(name = "parserPoolHolder") public ParserPoolHolder parserPoolHolder() { return new ParserPoolHolder(); } - + // Bindings, encoders and decoders used for creating and parsing messages @Bean - public HttpClient httpClient() { - HttpClient httpClient = new HttpClient(this.multiThreadedHttpConnectionManager); - httpClient.getHostConfiguration().setHost( - fromHttpUrl(metadataURL).build().toUri().getHost()); - return httpClient; - } - + public HttpClient httpClient() { + HttpClient httpClient = new HttpClient(this.multiThreadedHttpConnectionManager); + httpClient.getHostConfiguration().setHost( + fromHttpUrl(metadataURL).build().toUri().getHost()); + return httpClient; + } + // SAML Authentication Provider responsible for validating of received SAML // messages @Bean @@ -139,77 +145,77 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter { samlAuthenticationProvider.setForcePrincipalAsString(false); return samlAuthenticationProvider; } - + // Provider of default SAML Context @Bean public SAMLContextProviderImpl contextProvider() { return new SAMLContextProviderImpl(); } - + // Initialization of OpenSAML library @Bean public static SAMLBootstrap sAMLBootstrap() { return new SAMLBootstrap(); } - + // Logger for SAML messages and events @Bean public SAMLDefaultLogger samlLogger() { return new SAMLDefaultLogger(); } - + // SAML 2.0 WebSSO Assertion Consumer @Bean public WebSSOProfileConsumer webSSOprofileConsumer() { return new WebSSOProfileConsumerImpl(); } - + // SAML 2.0 Holder-of-Key WebSSO Assertion Consumer @Bean public WebSSOProfileConsumerHoKImpl hokWebSSOprofileConsumer() { return new WebSSOProfileConsumerHoKImpl(); } - + // SAML 2.0 Web SSO profile @Bean public WebSSOProfile webSSOprofile() { return new WebSSOProfileImpl(); } - + // SAML 2.0 Holder-of-Key Web SSO profile @Bean public WebSSOProfileConsumerHoKImpl hokWebSSOProfile() { return new WebSSOProfileConsumerHoKImpl(); } - + // SAML 2.0 ECP profile @Bean public WebSSOProfileECPImpl ecpprofile() { return new WebSSOProfileECPImpl(); } - + @Bean public SingleLogoutProfile logoutprofile() { return new SingleLogoutProfileImpl(); } - + @Bean - public KeyManager keyManager() { - DefaultResourceLoader loader = new DefaultResourceLoader(); - Resource storeFile = loader.getResource(config.getKeyStore()); - String storePass = config.getKeystorePassword(); - Map<String, String> passwords = new HashMap<String, String>(); - passwords.put(config.getKeystoreAlias(), config.getKeystorePassword()); - return new JKSKeyManager(storeFile, storePass, passwords, config.getKeystoreAlias()); - } - + public KeyManager keyManager() { + DefaultResourceLoader loader = new DefaultResourceLoader(); + Resource storeFile = loader.getResource(config.getKeyStore()); + String storePass = config.getKeystorePassword(); + Map<String, String> passwords = new HashMap<String, String>(); + passwords.put(config.getKeystoreAlias(), config.getKeystorePassword()); + return new JKSKeyManager(storeFile, storePass, passwords, config.getKeystoreAlias()); + } + @Bean public WebSSOProfileOptions defaultWebSSOProfileOptions() { WebSSOProfileOptions webSSOProfileOptions = new WebSSOProfileOptions(); webSSOProfileOptions.setIncludeScoping(false); return webSSOProfileOptions; } - + // Entry point to initialize authentication, default values taken from // properties file @Bean @@ -218,19 +224,19 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter { samlEntryPoint.setDefaultProfileOptions(defaultWebSSOProfileOptions()); return samlEntryPoint; } - - // Setup advanced info about metadata - @Bean - public ExtendedMetadata extendedMetadata() { - ExtendedMetadata extendedMetadata = new ExtendedMetadata(); - extendedMetadata.setIdpDiscoveryEnabled(false); - extendedMetadata.setSignMetadata(false); - extendedMetadata.setEcpEnabled(false); - extendedMetadata.setSslHostnameVerification("allowAll"); - extendedMetadata.setSigningKey("spring"); - return extendedMetadata; - } - + + // Setup advanced info about metadata + @Bean + public ExtendedMetadata extendedMetadata() { + ExtendedMetadata extendedMetadata = new ExtendedMetadata(); + extendedMetadata.setIdpDiscoveryEnabled(false); + extendedMetadata.setSignMetadata(false); + extendedMetadata.setEcpEnabled(false); + extendedMetadata.setSslHostnameVerification("allowAll"); + extendedMetadata.setSigningKey("spring"); + return extendedMetadata; + } + // IDP Discovery Service @Bean public SAMLDiscovery samlIDPDiscovery() { @@ -238,46 +244,46 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter { idpDiscovery.setIdpSelectionPath("/saml/idpSelection"); return idpDiscovery; } - - @Bean(name = "metadata") - public CachingMetadataManager metadata() throws MetadataProviderException { - - HTTPMetadataProvider provider = new HTTPMetadataProvider(this.backgroundTaskTimer, httpClient(), - this.metadataURL); - - //provider.setMinRefreshDelay(3600000); - //provider.setMinRefreshDelay(3600000); - provider.setParserPool(parserPool()); - - ExtendedMetadataDelegate extendedMetadataDelegate = new ExtendedMetadataDelegate(provider, extendedMetadata()); - extendedMetadataDelegate.setMetadataTrustCheck(false); - extendedMetadataDelegate.setMetadataRequireSignature(false); - backgroundTaskTimer.purge(); - - List<MetadataProvider> providers = new ArrayList<MetadataProvider>(); - providers.add(extendedMetadataDelegate); - return new CachingMetadataManager(providers); - - } - - @Bean - public MetadataGenerator metadataGenerator() { - MetadataGenerator metadataGenerator = new MetadataGenerator(); - metadataGenerator.setEntityId(config.getEntityName()); - metadataGenerator.setEntityBaseURL(config.getEntityBaseURL()); - metadataGenerator.setExtendedMetadata(extendedMetadata()); - metadataGenerator.setIncludeDiscoveryExtension(false); - metadataGenerator.setKeyManager(keyManager()); - return metadataGenerator; - } - + + @Bean(name = "metadata") + public CachingMetadataManager metadata() throws MetadataProviderException { + + HTTPMetadataProvider provider = new HTTPMetadataProvider(this.backgroundTaskTimer, httpClient(), + this.metadataURL); + + //provider.setMinRefreshDelay(3600000); + //provider.setMinRefreshDelay(3600000); + provider.setParserPool(parserPool()); + + ExtendedMetadataDelegate extendedMetadataDelegate = new ExtendedMetadataDelegate(provider, extendedMetadata()); + extendedMetadataDelegate.setMetadataTrustCheck(false); + extendedMetadataDelegate.setMetadataRequireSignature(false); + backgroundTaskTimer.purge(); + + List<MetadataProvider> providers = new ArrayList<MetadataProvider>(); + providers.add(extendedMetadataDelegate); + return new CachingMetadataManager(providers); + + } + + @Bean + public MetadataGenerator metadataGenerator() { + MetadataGenerator metadataGenerator = new MetadataGenerator(); + metadataGenerator.setEntityId(config.getEntityName()); + metadataGenerator.setEntityBaseURL(config.getEntityBaseURL()); + metadataGenerator.setExtendedMetadata(extendedMetadata()); + metadataGenerator.setIncludeDiscoveryExtension(false); + metadataGenerator.setKeyManager(keyManager()); + return metadataGenerator; + } + // The filter is waiting for connections on URL suffixed with filterSuffix // and presents SP metadata there @Bean public MetadataDisplayFilter metadataDisplayFilter() { return new MetadataDisplayFilter(); } - + // Handler deciding where to redirect user after successful login @Bean public SavedRequestAwareAuthenticationSuccessHandler successRedirectHandler() { @@ -287,17 +293,17 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter { LOGGER.info("executing the authentication success handler"); return successRedirectHandler; } - - // Handler deciding where to redirect user after failed login + + // Handler deciding where to redirect user after failed login @Bean public SimpleUrlAuthenticationFailureHandler authenticationFailureHandler() { - SimpleUrlAuthenticationFailureHandler failureHandler = - new SimpleUrlAuthenticationFailureHandler(); - failureHandler.setUseForward(true); - failureHandler.setDefaultFailureUrl("/error"); - return failureHandler; + SimpleUrlAuthenticationFailureHandler failureHandler = + new SimpleUrlAuthenticationFailureHandler(); + failureHandler.setUseForward(true); + failureHandler.setDefaultFailureUrl("/error"); + return failureHandler; } - + @Bean public SAMLWebSSOHoKProcessingFilter samlWebSSOHoKProcessingFilter() throws Exception { SAMLWebSSOHoKProcessingFilter samlWebSSOHoKProcessingFilter = new SAMLWebSSOHoKProcessingFilter(); @@ -306,7 +312,7 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter { samlWebSSOHoKProcessingFilter.setAuthenticationFailureHandler(authenticationFailureHandler()); return samlWebSSOHoKProcessingFilter; } - + // Processing filter for WebSSO profile messages @Bean public SAMLProcessingFilter samlWebSSOProcessingFilter() throws Exception { @@ -316,12 +322,12 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter { samlWebSSOProcessingFilter.setAuthenticationFailureHandler(authenticationFailureHandler()); return samlWebSSOProcessingFilter; } - + @Bean public MetadataGeneratorFilter metadataGeneratorFilter() { return new MetadataGeneratorFilter(metadataGenerator()); } - + // Handler for successful logout @Bean public SimpleUrlLogoutSuccessHandler successLogoutHandler() { @@ -329,17 +335,17 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter { successLogoutHandler.setDefaultTargetUrl("/"); return successLogoutHandler; } - + // Logout handler terminating local session @Bean public SecurityContextLogoutHandler logoutHandler() { - SecurityContextLogoutHandler logoutHandler = - new SecurityContextLogoutHandler(); + SecurityContextLogoutHandler logoutHandler = + new SecurityContextLogoutHandler(); logoutHandler.setInvalidateHttpSession(true); logoutHandler.setClearAuthentication(true); return logoutHandler; } - + // Filter processing incoming logout messages // First argument determines URL user will be redirected to after successful // global logout @@ -348,59 +354,59 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter { return new SAMLLogoutProcessingFilter(successLogoutHandler(), logoutHandler()); } - + // Overrides default logout processing filter with the one processing SAML // messages @Bean public SAMLLogoutFilter samlLogoutFilter() { return new SAMLLogoutFilter(successLogoutHandler(), - new LogoutHandler[] { logoutHandler() }, - new LogoutHandler[] { logoutHandler() }); + new LogoutHandler[]{logoutHandler()}, + new LogoutHandler[]{logoutHandler()}); } - + @Bean public HTTPSOAP11Binding soapBinding() { return new HTTPSOAP11Binding(parserPool()); } - + @Bean public HTTPPostBinding httpPostBinding() { - return new HTTPPostBinding(parserPool(), velocityEngine()); + return new HTTPPostBinding(parserPool(), velocityEngine()); } - + @Bean public HTTPRedirectDeflateBinding httpRedirectDeflateBinding() { - return new HTTPRedirectDeflateBinding(parserPool()); + return new HTTPRedirectDeflateBinding(parserPool()); } - + @Bean public HTTPSOAP11Binding httpSOAP11Binding() { - return new HTTPSOAP11Binding(parserPool()); + return new HTTPSOAP11Binding(parserPool()); } - + @Bean public HTTPPAOS11Binding httpPAOS11Binding() { - return new HTTPPAOS11Binding(parserPool()); + return new HTTPPAOS11Binding(parserPool()); } - + // Processor - @Bean - public SAMLProcessorImpl processor() { - Collection<SAMLBinding> bindings = new ArrayList<SAMLBinding>(); - bindings.add(httpRedirectDeflateBinding()); - bindings.add(httpPostBinding()); - //bindings.add(artifactBinding(parserPool(), velocityEngine())); - bindings.add(httpSOAP11Binding()); - bindings.add(httpPAOS11Binding()); - return new SAMLProcessorImpl(bindings); - } - - /** - * Define the security filter chain in order to support SSO Auth by using SAML 2.0 - * - * @return Filter chain proxy - * @throws Exception - */ + @Bean + public SAMLProcessorImpl processor() { + Collection<SAMLBinding> bindings = new ArrayList<SAMLBinding>(); + bindings.add(httpRedirectDeflateBinding()); + bindings.add(httpPostBinding()); + //bindings.add(artifactBinding(parserPool(), velocityEngine())); + bindings.add(httpSOAP11Binding()); + bindings.add(httpPAOS11Binding()); + return new SAMLProcessorImpl(bindings); + } + + /** + * Define the security filter chain in order to support SSO Auth by using SAML 2.0 + * + * @return Filter chain proxy + * @throws Exception + */ @Bean public FilterChainProxy samlFilter() throws Exception { List<SecurityFilterChain> chains = new ArrayList<SecurityFilterChain>(); @@ -420,57 +426,67 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter { samlIDPDiscovery())); return new FilterChainProxy(chains); } - + /** * Returns the authentication manager currently used by Spring. * It represents a bean definition with the aim allow wiring from * other classes performing the Inversion of Control (IoC). - * - * @throws Exception + * + * @throws Exception */ @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } - + /** * Defines the web based security configuration. - * - * @param http It allows configuring web based security for specific http requests. - * @throws Exception + * + * @param http It allows configuring web based security for specific http requests. + * @throws Exception */ - @Override + @Override protected void configure(HttpSecurity http) throws Exception { http - .httpBasic() + .httpBasic() .authenticationEntryPoint(samlEntryPoint()); http - .csrf() - .disable(); + .csrf() + .disable(); http - .addFilterBefore(metadataGeneratorFilter(), ChannelProcessingFilter.class) - .addFilterAfter(samlFilter(), BasicAuthenticationFilter.class); - + .addFilterBefore(metadataGeneratorFilter(), ChannelProcessingFilter.class) + .addFilterAfter(samlFilter(), BasicAuthenticationFilter.class); + http.authorizeRequests() - .antMatchers("/authenticate*/**").authenticated() - .anyRequest().permitAll(); - + .antMatchers("/authenticate*/**").authenticated() + .anyRequest().permitAll(); + http - .logout() + .logout() .logoutSuccessUrl("/"); } - + /** * Sets a custom authentication provider. - * - * @param auth SecurityBuilder used to create an AuthenticationManager. - * @throws Exception + * + * @param auth SecurityBuilder used to create an AuthenticationManager. + * @throws Exception */ @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth - .authenticationProvider(samlAuthenticationProvider()); - } + .authenticationProvider(samlAuthenticationProvider()); + } + + @Bean + public Map<String, LGUser.Group> groupMappings(SamlGroupMapping mappings) { + return mappings.groupMappings(); + } + + @Bean + public Map<SamlKeyMappings.Key, String> keyMappings(SamlKeyMappings mappings) { + return mappings.mappings(); + } } \ No newline at end of file diff --git a/src/main/java/org/geant/lgservice/services/LookingGlassService.java b/src/main/java/org/geant/lgservice/services/LookingGlassService.java index fb565f797475cdd6a87e05788d505fae78f95665..aa8e955f9aa0a7f5b8a79cd9cbd0bd4c705ceddf 100644 --- a/src/main/java/org/geant/lgservice/services/LookingGlassService.java +++ b/src/main/java/org/geant/lgservice/services/LookingGlassService.java @@ -1,20 +1,17 @@ package org.geant.lgservice.services; -import java.util.List; -import java.util.Map; - import org.geant.lgservice.pojos.Command; import org.geant.lgservice.pojos.CommandOutput; import org.geant.lgservice.pojos.Group; -import org.geant.lgservice.pojos.Router; import org.geant.lgservice.security.LGUser; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.List; public interface LookingGlassService { - List<Router> getAllRouters(final LGUser user); - List<Group> getAllCommands(final LGUser user); - Map<String, CommandOutput> submitQuery(final List<Router> selectedRouters, final Command selectedCommand, - final String arguments, final boolean displayAsXML); + CommandOutput submitQuery(final String hostname, final Command selectedCommand, + final String arguments, final boolean displayAsXML, UserDetails user); } diff --git a/src/main/java/org/geant/lgservice/services/LookingGlassServiceImpl.java b/src/main/java/org/geant/lgservice/services/LookingGlassServiceImpl.java index 2965cddf54e3f5ebc2b65b5ad3ed8e9cea678ccf..be982e9769f993464cd323216760a391eead0540 100644 --- a/src/main/java/org/geant/lgservice/services/LookingGlassServiceImpl.java +++ b/src/main/java/org/geant/lgservice/services/LookingGlassServiceImpl.java @@ -1,21 +1,27 @@ package org.geant.lgservice.services; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - import lombok.RequiredArgsConstructor; -import org.geant.lgservice.ecmr.CommandExecutor; +import org.apache.commons.lang.StringUtils; +import org.geant.lgservice.domain.Router; +import org.geant.lgservice.domain.RouterRepository; +import org.geant.lgservice.exceptions.BusinessException; +import org.geant.lgservice.infrastructure.ssh.SSHCommandExecutor; import org.geant.lgservice.pojos.Command; import org.geant.lgservice.pojos.CommandOutput; import org.geant.lgservice.pojos.Group; -import org.geant.lgservice.pojos.Router; import org.geant.lgservice.security.LGUser; import org.geant.lgservice.utils.LookingGlassHelper; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Service; -import static java.util.stream.Collectors.toMap; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.geant.lgservice.domain.Router.Access.INTERNAL; +import static org.geant.lgservice.exceptions.BusinessException.Reason.FORBIDDEN; +import static org.geant.lgservice.exceptions.BusinessException.Reason.NOT_FOUND; +import static org.geant.lgservice.exceptions.BusinessException.Reason.UNAUTHORIZED; @Service @RequiredArgsConstructor @@ -23,7 +29,9 @@ public class LookingGlassServiceImpl implements LookingGlassService { private final LookingGlassHelper lookingGlassHelper; - private final CommandExecutor commandExecutor; + private final RouterRepository routerRepository; + + private final SSHCommandExecutor sshCommandExecutor; @Override public List<Group> getAllCommands(final LGUser user) { @@ -31,15 +39,29 @@ public class LookingGlassServiceImpl implements LookingGlassService { } @Override - public List<Router> getAllRouters(final LGUser user) { - return lookingGlassHelper.loadAllRouters(user); + public CommandOutput submitQuery(final String hostname, final Command selectedCommand, + final String arguments, final boolean displayAsXML, UserDetails user) { + + Router router = routerRepository.getByHostName(hostname) + .orElseThrow(() -> BusinessException.withReason(NOT_FOUND)); + + if (router.getAccess().equals(INTERNAL)) { + if (user == null){ + throw BusinessException.withReason(UNAUTHORIZED); + }else if (!user.getAuthorities().contains(LGUser.Group.INTERNAL)){ + throw BusinessException.withReason(FORBIDDEN); + } + } + + return sshCommandExecutor.execute(hostname, getCommandWithArguments(selectedCommand, arguments, displayAsXML)); } - @Override - public Map<String, CommandOutput> submitQuery(final List<Router> selectedRouters, final Command selectedCommand, - final String arguments, final boolean displayAsXML) { - return selectedRouters.stream() - .map(router -> commandExecutor.execute(router, selectedCommand, arguments, displayAsXML)) - .collect(toMap(CommandOutput::getRouterName, output -> output)); + private String getCommandWithArguments(Command command, String arguments, boolean displayAsXML) { + return Stream.of( + command.getValue(), + arguments, + displayAsXML ? "| display xml" : "") + .filter(StringUtils::isNotBlank) + .collect(Collectors.joining(" ")); } } diff --git a/src/main/java/org/geant/lgservice/utils/LookingGlassHelper.java b/src/main/java/org/geant/lgservice/utils/LookingGlassHelper.java index 8078d98428ae086efd8f2635dc788597f314b99b..11a5f495c14a775d7fee12fe1fe803abc5a03fc9 100755 --- a/src/main/java/org/geant/lgservice/utils/LookingGlassHelper.java +++ b/src/main/java/org/geant/lgservice/utils/LookingGlassHelper.java @@ -1,57 +1,20 @@ package org.geant.lgservice.utils; import java.util.*; -import java.util.stream.Collectors; -import java.util.stream.StreamSupport; import org.geant.lgservice.config.CommandsParser; -import org.geant.lgservice.model.Equipment; -import org.geant.lgservice.pojos.Coordinates; import org.geant.lgservice.pojos.Group; -import org.geant.lgservice.pojos.Router; -import org.geant.lgservice.repository.EquipmentRepository; import org.geant.lgservice.security.LGUser; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; -import static java.util.Spliterator.ORDERED; import static java.util.Spliterators.spliteratorUnknownSize; import static java.util.stream.StreamSupport.stream; -import static org.geant.lgservice.utils.LGRole.INTERNAL; @Component public class LookingGlassHelper { public static final String COMMANDS_FILE_NAME = "commands"; - @Autowired - private EquipmentRepository equipmentRepo; - - public List<Router> loadAllRouters(final LGUser user) { - final boolean internalRoutersAvailable = - Optional.ofNullable(user) - .map(LGUser::getRole) - .filter(INTERNAL::equals) - .isPresent(); - - - return stream(equipmentRepo.findAll().spliterator(), false) - .filter(equipment -> isValidRouter(internalRoutersAvailable, equipment)) - .map(Equipment::toDomain) - .collect(Collectors.toList()); - } - - private boolean isValidRouter(final Boolean showInternalRouters, final Equipment equipment) { - if (showInternalRouters) { - return true; - } else { - if (equipment.getInternal().equals("no")) { - return true; - } - } - return false; - } - public List<Group> loadAllCommands(final LGUser user) { return new CommandsParser().getCommandsFromXML(COMMANDS_FILE_NAME, user); } diff --git a/src/test/java/org/geant/lgservice/ecmr/CallableCommandExecutorTest.java b/src/test/java/org/geant/lgservice/ecmr/CallableCommandExecutorTest.java deleted file mode 100644 index 610bec5c79ef4b5f25d254a0e2489018675f05b7..0000000000000000000000000000000000000000 --- a/src/test/java/org/geant/lgservice/ecmr/CallableCommandExecutorTest.java +++ /dev/null @@ -1,55 +0,0 @@ -package org.geant.lgservice.ecmr; - -import static org.junit.Assert.assertEquals; - -import org.geant.lgservice.pojos.Command; -import org.geant.lgservice.pojos.Router; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.junit4.SpringRunner; - -@RunWith(SpringRunner.class) -@SpringBootTest -public class CallableCommandExecutorTest { - - public static final String TEST_ROUTER = "test-router.geant.net"; - public static final String TEST_COMMAND = "ping 25"; - - @Test - public void testGetCommandWithArgumentsXML() { - Router router = new Router(); - router.setName(TEST_ROUTER); - Command command = new Command(); - command.setValue(TEST_COMMAND); - command.setRequiresParams(true); - - CallableCommandExecutor callable = new CallableCommandExecutor(router, command, "param1", true, null); - assertEquals("ping 25 param1 | display xml", callable.getCommandWithArguments()); - } - - @Test - public void testGetCommandWithoutArgumentsXML() { - Router router = new Router(); - router.setName(TEST_ROUTER); - Command command = new Command(); - command.setValue(TEST_COMMAND); - command.setRequiresParams(false); - - CallableCommandExecutor callable = new CallableCommandExecutor(router, command, null, true, null); - assertEquals("ping 25 | display xml", callable.getCommandWithArguments()); - } - - @Test - public void testGetCommandWithoutArguments() { - Router router = new Router(); - router.setName(TEST_ROUTER); - Command command = new Command(); - command.setValue(TEST_COMMAND); - command.setRequiresParams(false); - - CallableCommandExecutor callable = new CallableCommandExecutor(router, command, null, false, null); - assertEquals("ping 25", callable.getCommandWithArguments()); - } - -} diff --git a/src/test/java/org/geant/lgservice/infrastructure/ssh/SSHHostTest.java b/src/test/java/org/geant/lgservice/infrastructure/ssh/SSHHostTest.java new file mode 100644 index 0000000000000000000000000000000000000000..9adef25bb14684f4c67041e91925ec77ff647c8e --- /dev/null +++ b/src/test/java/org/geant/lgservice/infrastructure/ssh/SSHHostTest.java @@ -0,0 +1,110 @@ +package org.geant.lgservice.infrastructure.ssh; + +import ch.ethz.ssh2.Connection; +import ch.ethz.ssh2.Session; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.SneakyThrows; +import org.geant.lgservice.exceptions.TechnicalException; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +import java.io.IOException; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.geant.lgservice.infrastructure.ssh.SSHHost.ConnectionStateListener.State.DISCONNECTED; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.when; + +public class SSHHostTest { + + private static final String HOSTNAME = "localhost"; + private static final String RESPONSE_STRING = "A Response String"; + + private Connection connection = mock(Connection.class); + private Session session = mock(Session.class); + private SSHHost subject; + + private PipedInputStream response; + private PipedOutputStream responseSource; + + @Before + @SneakyThrows + public void setup() { + reset(connection); + when(connection.isAuthenticationComplete()).thenReturn(true); + when(connection.openSession()).thenReturn(session); + when(connection.getHostname()).thenReturn(HOSTNAME); + + responseSource = new PipedOutputStream(); + response = new PipedInputStream(responseSource); + when(session.getStdout()).thenReturn(response); + when(session.getStderr()).thenReturn(response); + + responseSource.write(RESPONSE_STRING.getBytes()); + responseSource.close(); + + subject = SSHHost.fromConnection(connection); + } + + @Test + public void commandExecutedSuccessfully() { + when(session.getExitStatus()).thenReturn(0); + assertThat(subject.execute("some command")).isEqualTo(RESPONSE_STRING); + } + + @Test + public void commandExitedWithNonZeroStatus() { + when(session.getExitStatus()).thenReturn(1); + assertThat(subject.execute("some command")).isEqualTo(RESPONSE_STRING); + } + + @Test(expected = TechnicalException.class) + public void createWithUnAuthenticatedConnection() { + reset(connection); + when(connection.isAuthenticationComplete()).thenReturn(false); + SSHHost.fromConnection(connection); + fail("subject should have thrown an exception"); + } + + @Test(expected = TechnicalException.class) + public void sessionThrowsIOException() { + try { + Mockito.doThrow(new IOException()).when(session).execCommand(any(String.class)); + } catch (IOException e) { + fail("Mock should not throw IOException", e); + } + subject.execute("some command"); + fail("subject should have thrown an exception"); + } + + @Getter + @Setter + @NoArgsConstructor + private static class StateListenerOutput { + private String host; + private SSHHost.ConnectionStateListener.State state; + } + + @Test + public void connectionStateListenersTriggered() { + final StateListenerOutput listenerOutput = new StateListenerOutput(); + + subject.withConnectionStateListener((host, state) -> { + listenerOutput.setHost(host.hostname()); + listenerOutput.setState(state); + }); + + subject.connectionLost(new IOException()); + + assertThat(listenerOutput.getHost()).isEqualTo(HOSTNAME); + assertThat(listenerOutput.getState()).isEqualTo(DISCONNECTED); + } +} diff --git a/src/test/java/org/geant/lgservice/integration/BaseIT.java b/src/test/java/org/geant/lgservice/integration/BaseIT.java new file mode 100644 index 0000000000000000000000000000000000000000..a0ab1a60a811290c37d943d90002562dfc2566b8 --- /dev/null +++ b/src/test/java/org/geant/lgservice/integration/BaseIT.java @@ -0,0 +1,97 @@ +package org.geant.lgservice.integration; + +import ch.ethz.ssh2.Connection; +import ch.ethz.ssh2.Session; +import com.alexlovett.jgivenextension.ExtendedScenarioTest; +import com.github.tomakehurst.wiremock.junit.WireMockClassRule; +import com.tngtech.jgiven.integration.spring.SpringStageCreator; +import lombok.SneakyThrows; +import org.geant.lgservice.LookingGlassServiceApplication; +import org.geant.lgservice.infrastructure.ssh.ConnectionFactory; +import org.geant.lgservice.infrastructure.ssh.ConnectionManager; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; + +import java.lang.reflect.Field; +import java.util.Map; + +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.reset; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; +import static org.springframework.test.annotation.DirtiesContext.ClassMode.BEFORE_CLASS; + +@RunWith(SpringRunner.class) +@SpringBootTest( + webEnvironment = RANDOM_PORT, + classes = {LookingGlassServiceApplication.class, Config.class} +) +@TestPropertySource(properties = { + "server.ssl.enabled=false" +}) +@DirtiesContext(classMode = BEFORE_CLASS) +public abstract class BaseIT <GIVEN, WHEN, THEN> extends ExtendedScenarioTest<GIVEN, WHEN, THEN> implements BeanFactoryAware { + @LocalServerPort + protected int port; + + @Autowired + private PortSink portSink; + + @Autowired + private ConnectionFactory connectionFactory; + + @Autowired + private ConnectionManager connectionManager; + + @Autowired + private Connection connection; + + @Autowired + private Session session; + + interface PortSink { + void sink(int port); + } + + @ClassRule + public static final WireMockClassRule INVENTORY_PROVIDER = new WireMockClassRule(wireMockConfig().dynamicPort()); + + @Before + @SneakyThrows + public void setup() { + portSink.sink(port); + INVENTORY_PROVIDER.resetAll(); + reset(connectionFactory); + reset(connection); + reset(session); + Mockito.when(connectionFactory.createConnection(any(String.class))).thenReturn(connection); + Mockito.when(connection.openSession()).thenReturn(session); + Mockito.when(connection.isAuthenticationComplete()).thenReturn(true); + + Field hostField = ConnectionManager.class.getDeclaredField("hosts"); + hostField.setAccessible(true); + Map hosts = (Map)hostField.get(connectionManager); + hosts.clear(); + } + + @Override + public void setBeanFactory( BeanFactory beanFactory ) { + getScenario().setStageCreator( beanFactory.getBean( SpringStageCreator.class ) ); + } + + @BeforeClass + public static void setupClass() { + System.setProperty("inventoryprovider.baseUrl", String.format("http://localhost:%s/lg/", INVENTORY_PROVIDER.port())); + } +} diff --git a/src/test/java/org/geant/lgservice/integration/Config.java b/src/test/java/org/geant/lgservice/integration/Config.java new file mode 100644 index 0000000000000000000000000000000000000000..137c9338bedb4d01bc112ffe364a5bc9bc4b3721 --- /dev/null +++ b/src/test/java/org/geant/lgservice/integration/Config.java @@ -0,0 +1,99 @@ +package org.geant.lgservice.integration; + +import ch.ethz.ssh2.Connection; +import ch.ethz.ssh2.Session; +import com.tngtech.jgiven.integration.spring.EnableJGiven; +import org.geant.lgservice.infrastructure.ssh.ConnectionFactory; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.http.client.support.HttpRequestWrapper; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; +import java.net.URI; + +import static java.util.Optional.of; +import static org.geant.lgservice.integration.Context.withDefaults; + +@Configuration +@EnableJGiven +@ComponentScan(basePackages = { "org.geant.lgservice.integration"}) +@MockBean({ + ConnectionFactory.class, + Connection.class, + Session.class +}) +public class Config { + + @Bean + public RestTemplate restTemplate(PortInjectingInterceptor interceptor) { + return new RestTemplateBuilder() + .rootUri("http://localhost") + .interceptors(interceptor) + .messageConverters(new MappingJackson2HttpMessageConverter()) + .build(); + } + + @Bean + PortInjectingInterceptor portInjector() { + return new PortInjectingInterceptor(); + } + + static class PortInjectingInterceptor implements ClientHttpRequestInterceptor, BaseIT.PortSink { + + private int port; + + @Override + public ClientHttpResponse intercept( + HttpRequest httpRequest, + byte[] bytes, + ClientHttpRequestExecution clientHttpRequestExecution) throws IOException { + + HttpRequest req = new HttpRequestWrapper(httpRequest) { + @Override + public URI getURI() { + return of(port) + .map(port -> UriComponentsBuilder.fromUri(super.getURI()) + .host("localhost") + .port(port) + .build() + .toUri()) + .get(); + } + }; + + return clientHttpRequestExecution.execute(req, bytes); + } + + @Override + public void sink(int port) { + this.port = port; + } + } + + @Bean + public Context context(){ + return withDefaults(); + } + + @Bean + public MockMvc mockMvc(WebApplicationContext context) { + return MockMvcBuilders.webAppContextSetup(context) + .apply(SecurityMockMvcConfigurers.springSecurity()) + .build(); + } + +} diff --git a/src/test/java/org/geant/lgservice/integration/Context.java b/src/test/java/org/geant/lgservice/integration/Context.java new file mode 100644 index 0000000000000000000000000000000000000000..f97e2afd50709426ae60d282815d67645bff758c --- /dev/null +++ b/src/test/java/org/geant/lgservice/integration/Context.java @@ -0,0 +1,151 @@ +package org.geant.lgservice.integration; + +import ch.ethz.ssh2.Connection; +import ch.ethz.ssh2.Session; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.stubbing.StubMapping; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.geant.lgservice.security.LGUser; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static org.geant.lgservice.integration.Context.Router.INTERNAL; +import static org.geant.lgservice.integration.Context.Router.PUBLIC; +import static org.mockito.Mockito.mock; + +@Getter +@Builder +public class Context { + + @Getter + @RequiredArgsConstructor + enum Router { + PUBLIC("mx2.tal.ee.geant.net"), + INTERNAL("mx1.cbg.uk.geant.net"); + + private final String host; + } + + private Router router; + private String command; + private LGUser user; + + private StubMapping inventoryProviderMapping; + + private int responseStatus; + private SubmitResponseBody submitResponseBody; + private Collection<CommandGroup> commandResponseBody; + + public Context withPublicRouter() { + this.router = PUBLIC; + return this; + } + + public Context withInternalRouter() { + this.router = INTERNAL; + return this; + } + + public Context withCommand(String command) { + this.command = command; + return this; + } + + public Context withUser(LGUser user) { + this.user = user; + return this; + } + + public Context withInventoryProviderStub(StubMapping mapping) { + this.inventoryProviderMapping = mapping; + return this; + } + + public Context withResponseStatus(int responseStatus) { + this.responseStatus = responseStatus; + return this; + } + + public Context withSubmitResponseBody(SubmitResponseBody submitResponseBody) { + this.submitResponseBody = submitResponseBody; + return this; + } + + public Context withCommandResponseBody(Collection<CommandGroup> commandResponseBody) { + this.commandResponseBody = commandResponseBody; + return this; + } + + public String getRouterHost() { + return router.getHost(); + } + + @SneakyThrows + public static Context withDefaults() { + return Context.builder() + .inventoryProviderMapping(stubFor(get("/lg/routers/all") + .willReturn(WireMock.aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBodyFile("routers.json")))) + .build() + .withPublicRouter() + .withCommand("show config"); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class SubmitResponseBody { + + private final Map<String, CommandResult> output; + + @JsonCreator + public SubmitResponseBody(@JsonProperty("output") Map<String, CommandResult> output) { + this.output = output; + } + + public CommandResult result() { + return output.values() + .stream() + .findFirst() + .orElseThrow(() -> new AssertionError("There should be command result in the response")); + } + + @Getter + public static class CommandResult { + + private String routerName; + @JsonProperty("commandResult") + private String response; + private Long executionTime; + } + } + + @Getter + public static class CommandGroup { + + private List<Command> commands; + private String name; + + @Getter + public static class Command { + private String name; + private String value; + private String description; + private boolean requiresParams; + } + } + + +} diff --git a/src/test/java/org/geant/lgservice/integration/scenario/GetCommandsIT.java b/src/test/java/org/geant/lgservice/integration/scenario/GetCommandsIT.java new file mode 100644 index 0000000000000000000000000000000000000000..da1b79cd83491c2f084654001b4a04a78c33cac5 --- /dev/null +++ b/src/test/java/org/geant/lgservice/integration/scenario/GetCommandsIT.java @@ -0,0 +1,66 @@ +package org.geant.lgservice.integration.scenario; + +import org.geant.lgservice.integration.BaseIT; +import org.junit.Test; + +import static org.geant.lgservice.integration.scenario.Then.ThenResponse.ThenCommandResponse.UserType.EXTERNAL; +import static org.geant.lgservice.integration.scenario.Then.ThenResponse.ThenCommandResponse.UserType.INTERNAL; +import static org.geant.lgservice.integration.scenario.Then.ThenResponse.ThenCommandResponse.UserType.PUBLIC; + +public class GetCommandsIT extends BaseIT<Given, When, Then> { + + @Test + public void anonymous_user_requests_commands() { + given() + .a().user() + .that().is().anonymous(); + + when() + .a().request_for_available_commands_is_sent(); + + then() + .the().response() + .is().ok() + .and().has_commands() + .that().are_for_$_users(PUBLIC) + .and().are_not_for_$_users(EXTERNAL) + .and().are_not_for_$_users(INTERNAL); + } + + @Test + public void external_user_requests_commands() { + given() + .a().user() + .that().is().logged_in() + .but().does_not_have_internal_access(); + + when() + .a().request_for_available_commands_is_sent(); + + then() + .the().response() + .is().ok() + .and().has_commands() + .that().are_for_$_users(PUBLIC) + .and().are_for_$_users(EXTERNAL) + .and().are_not_for_$_users(INTERNAL); + } + + @Test + public void internal_user_requests_commands() { + given() + .a().user() + .that().is().logged_in(); + + when() + .a().request_for_available_commands_is_sent(); + + then() + .the().response() + .is().ok() + .and().has_commands() + .that().are_for_$_users(PUBLIC) + .and().are_for_$_users(EXTERNAL) + .and().are_for_$_users(INTERNAL); + } +} diff --git a/src/test/java/org/geant/lgservice/integration/scenario/Given.java b/src/test/java/org/geant/lgservice/integration/scenario/Given.java new file mode 100644 index 0000000000000000000000000000000000000000..6dc87eeb717e2c1707f1a50056f0ad706c6e0aff --- /dev/null +++ b/src/test/java/org/geant/lgservice/integration/scenario/Given.java @@ -0,0 +1,169 @@ +package org.geant.lgservice.integration.scenario; + +import ch.ethz.ssh2.Connection; +import ch.ethz.ssh2.Session; +import com.alexlovett.jgivenextension.ExtendedStage; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.http.ResponseDefinition; +import com.github.tomakehurst.wiremock.stubbing.StubMapping; +import com.tngtech.jgiven.annotation.AfterStage; +import com.tngtech.jgiven.annotation.BeforeScenario; +import com.tngtech.jgiven.annotation.ExpectedScenarioState; +import com.tngtech.jgiven.annotation.ProvidedScenarioState; +import com.tngtech.jgiven.integration.spring.JGivenStage; +import lombok.SneakyThrows; +import org.geant.lgservice.infrastructure.ssh.ConnectionFactory; +import org.geant.lgservice.integration.BaseIT; +import org.geant.lgservice.integration.Context; +import org.geant.lgservice.security.LGUser; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; + +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.util.Collections; + +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static org.geant.lgservice.integration.Context.withDefaults; +import static org.geant.lgservice.security.LGUser.Group.EXTERNAL; +import static org.geant.lgservice.security.LGUser.Group.INTERNAL; +import static org.mockito.ArgumentMatchers.any; + +@JGivenStage +public class Given extends ExtendedStage<Given> { + + @ProvidedScenarioState + private Context context; + + @Autowired + @ProvidedScenarioState + private ConnectionFactory connectionFactory; + + @Autowired + @ProvidedScenarioState + private Session session; + + @BeforeScenario + public void setup() { + context = withDefaults(); + } + + public GivenUser user() { + return nest(GivenUser.class); + } + + public static class GivenUser extends NestedStage<GivenUser, Given> { + + @ExpectedScenarioState + private Context context; + + public GivenUser anonymous() { + return self(); + } + + public GivenUser logged_in() { + context.withUser( + LGUser.builder() + .federatedUser("1") + .givenName("barry") + .surname("scott") + .mail("barry.scott@geant.org") + .group(INTERNAL) + .group(EXTERNAL) + .build()); + return self(); + } + + public GivenUser does_not_have_internal_access() { + LGUser user = context.getUser(); + context.withUser( + LGUser.builder() + .federatedUser(user.getFederatedUser()) + .givenName(user.getGivenName()) + .surname(user.getSurname()) + .mail(user.getMail()) + .group(EXTERNAL) + .build()); + return self(); + } + } + + public GivenRouter router() { + return nest(GivenRouter.class); + } + + public static class GivenRouter extends NestedStage<GivenRouter, Given> { + + @ExpectedScenarioState + private Context context; + + @Autowired + @ExpectedScenarioState + private ConnectionFactory connectionFactory; + + @ExpectedScenarioState + private Session session; + + public GivenRouter publicly_accessible() { + context.withPublicRouter(); + return self(); + } + + public GivenRouter internal() { + context.withInternalRouter(); + return self(); + } + + @SneakyThrows + public GivenRouter ok() { + PipedOutputStream responseSource = new PipedOutputStream(); + PipedInputStream response = new PipedInputStream(responseSource); + + Mockito.when(session.getStdout()).thenReturn(response); + + Mockito.when(session.getExitStatus()) + .thenReturn(0); + + Mockito.doAnswer(invocation -> { + responseSource.write(invocation.getArgument(0, String.class).getBytes()); + responseSource.flush(); + responseSource.close(); + return null; + }).when(session).execCommand(any(String.class)); + + return self(); + } + } + + public GivenInventoryProvider inventory_provider() { + return nest(GivenInventoryProvider.class); + } + + public static class GivenInventoryProvider extends NestedStage<GivenInventoryProvider, Given> { + + @ExpectedScenarioState + private Context context; + + public GivenInventoryProvider returns_(int status) { + context.withInventoryProviderStub(get("/lg/routers/all") + .willReturn(WireMock.aResponse() + .withStatus(status)) + .build()); + return self(); + } + + public GivenInventoryProvider bad_body() { + StubMapping mapping = context.getInventoryProviderMapping(); + ResponseDefinition response = mapping.getResponse(); + mapping.setResponse(WireMock.aResponse().withStatus(response.getStatus()) + .withBody("{\"This is not valid response body\":true").build()); + return self(); + } + } + + @AfterStage + public void setupMocks() { + BaseIT.INVENTORY_PROVIDER.resetAll(); + BaseIT.INVENTORY_PROVIDER.addStubMapping(context.getInventoryProviderMapping()); + } +} diff --git a/src/test/java/org/geant/lgservice/integration/scenario/SubmitCommandRequestIT.java b/src/test/java/org/geant/lgservice/integration/scenario/SubmitCommandRequestIT.java new file mode 100644 index 0000000000000000000000000000000000000000..503caa603154bffa8984e78ad745a427b4a40e7d --- /dev/null +++ b/src/test/java/org/geant/lgservice/integration/scenario/SubmitCommandRequestIT.java @@ -0,0 +1,161 @@ +package org.geant.lgservice.integration.scenario; + +import org.geant.lgservice.integration.BaseIT; +import org.junit.Test; + +public class SubmitCommandRequestIT extends BaseIT<Given, When, Then> { + + @Test + public void anonymous_user_sends_command_to_public_router(){ + given() + .a().user() + .that().is().anonymous().backup() + .and().a().router() + .that().is().publicly_accessible() + .and().is().ok(); + + when() + .a().request_to_the_router_is_sent(); + + then() + .the().command_is_executed() + .and().the().result_is_returned(); + } + + @Test + public void anonymous_user_sends_command_to_internal_router() { + given() + .a().user() + .that().is().anonymous().backup() + .and().a().router() + .that().is().internal(); + + when() + .a().request_to_the_router_is_sent(); + + then() + .the().response() + .is().an().error() + .with_a().status(401); + } + + @Test + public void logged_in_user_sends_command_to_public_router() { + given() + .a().user() + .that().is().logged_in() + .backup() + .and().a().router() + .that().is().publicly_accessible() + .and().is().ok(); + + when() + .a().request_to_the_router_is_sent(); + + then() + .the().command_is_executed() + .and().the().result_is_returned(); + } + + @Test + public void logged_in_user_sends_command_to_internal_router() { + given() + .a().user() + .that().is().logged_in() + .backup() + .and().a().router() + .that().is().internal() + .and().is().ok(); + + when() + .a().request_to_the_router_is_sent(); + + then() + .the().command_is_executed() + .and().the().result_is_returned(); + } + + @Test + public void logged_in_user_without_internal_access_sends_command_to_internal_router() { + given() + .a().user() + .that().is().logged_in() + .but().does_not_have_internal_access() + .backup() + .and().a().router() + .that().is().internal(); + + when() + .a().request_to_the_router_is_sent(); + + then() + .the().response() + .is().an().error() + .with_a().status(403); + } + + @Test + public void inventory_provider_returns_client_error() { + given() + .a().user() + .that().is().anonymous() + .backup() + .and().a().router() + .that().is().publicly_accessible() + .backup() + .and().inventory_provider() + .returns_(400); + + when() + .a().request_to_the_router_is_sent(); + + then() + .the().response() + .is().an().error() + .with().status(500); + } + + @Test + public void inventory_provider_returns_server_error() { + given() + .a().user() + .that().is().anonymous() + .backup() + .and().a().router() + .that().is().publicly_accessible() + .backup() + .and().inventory_provider() + .returns_(500); + + when() + .a().request_to_the_router_is_sent(); + + then() + .the().response() + .is().an().error() + .with().status(500); + } + + @Test + public void inventory_provider_returns_invalid_body() { + given() + .a().user() + .that().is().anonymous() + .backup() + .and().a().router() + .that().is().publicly_accessible() + .backup() + .and().inventory_provider() + .returns_(200) + .with().a().bad_body(); + + when() + .a().request_to_the_router_is_sent(); + + then() + .the().response() + .is().an().error() + .with().status(500); + } + +} diff --git a/src/test/java/org/geant/lgservice/integration/scenario/Then.java b/src/test/java/org/geant/lgservice/integration/scenario/Then.java new file mode 100644 index 0000000000000000000000000000000000000000..6af304fde81cdec81ed831c30f6fdfc6aab6cc02 --- /dev/null +++ b/src/test/java/org/geant/lgservice/integration/scenario/Then.java @@ -0,0 +1,112 @@ +package org.geant.lgservice.integration.scenario; + +import ch.ethz.ssh2.Session; +import com.alexlovett.jgivenextension.ExtendedStage; +import com.tngtech.jgiven.annotation.ExpectedScenarioState; +import com.tngtech.jgiven.integration.spring.JGivenStage; +import lombok.SneakyThrows; +import org.geant.lgservice.integration.Context; +import org.mockito.ArgumentCaptor; + +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@JGivenStage +public class Then extends ExtendedStage<Then> { + + @ExpectedScenarioState + private Context context; + + @ExpectedScenarioState + private Session session; + + @SneakyThrows + public Then command_is_executed() { + ArgumentCaptor<String> command = ArgumentCaptor.forClass(String.class); + + verify(session, times(1)) + .execCommand(command.capture()); + assertThat(command.getValue()).isEqualTo(context.getCommand()); + return self(); + } + + public Then result_is_returned() { + Context.SubmitResponseBody submitResponseBody = context.getSubmitResponseBody(); + assertThat(submitResponseBody).isNotNull(); + + Context.SubmitResponseBody.CommandResult result = submitResponseBody.result(); + assertThat(result).isNotNull(); + assertThat(result.getRouterName()).isEqualTo(context.getRouterHost()); + assertThat(result.getResponse()).isEqualTo(context.getCommand()); + + return self(); + } + + public ThenResponse response() { + return nest(ThenResponse.class); + } + + public static class ThenResponse extends NestedStage<ThenResponse, Then> { + + @ExpectedScenarioState + private Context context; + + public ThenResponseError error() { + assertThat(context.getResponseStatus()).isNotEqualTo(200); + return nest(ThenResponseError.class); + } + + public ThenResponse ok() { + assertThat(context.getResponseStatus()).isEqualTo(200); + return self(); + } + + public static class ThenResponseError extends NestedStage<ThenResponseError, ThenResponse> { + + @ExpectedScenarioState + private Context context; + + public ThenResponseError status(int status) { + assertThat(context.getResponseStatus()).isEqualTo(status); + return self(); + } + + public ThenResponseError with_a() { + return self(); + } + } + + public ThenCommandResponse has_commands() { + assertThat(context.getCommandResponseBody()).isNotNull(); + return nest(ThenCommandResponse.class); + } + + public static class ThenCommandResponse extends NestedStage<ThenCommandResponse, ThenResponse> { + @ExpectedScenarioState + private Context context; + + public enum UserType { + PUBLIC, + EXTERNAL, + INTERNAL + } + + public ThenCommandResponse are_for_$_users(UserType userType) { + assertThat(context.getCommandResponseBody().stream().map(Context.CommandGroup::getName).collect(Collectors.toList())) + .contains(String.format("%s-group", userType.name().toLowerCase())); + return self(); + } + + public ThenCommandResponse are_not_for_$_users(UserType userType) { + assertThat(context.getCommandResponseBody().stream().map(Context.CommandGroup::getName).collect(Collectors.toList())) + .doesNotContain(String.format("%s-group", userType.name().toLowerCase())); + return self(); + } + } + + } + +} diff --git a/src/test/java/org/geant/lgservice/integration/scenario/When.java b/src/test/java/org/geant/lgservice/integration/scenario/When.java new file mode 100644 index 0000000000000000000000000000000000000000..7fa83d8f0de4284e5bad4beddfacb70d9c355647 --- /dev/null +++ b/src/test/java/org/geant/lgservice/integration/scenario/When.java @@ -0,0 +1,132 @@ +package org.geant.lgservice.integration.scenario; + +import com.alexlovett.jgivenextension.ExtendedStage; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tngtech.jgiven.annotation.ExpectedScenarioState; +import com.tngtech.jgiven.integration.spring.JGivenStage; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.geant.lgservice.integration.Context; +import org.geant.lgservice.integration.Context.SubmitResponseBody; +import org.geant.lgservice.security.LGUser; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpMethod; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.lang.reflect.Type; +import java.util.Collection; + +import static java.util.Collections.singletonList; +import static org.springframework.http.HttpMethod.GET; +import static org.springframework.http.HttpMethod.POST; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; + +@JGivenStage +@Slf4j +public class When extends ExtendedStage<When> { + + @ExpectedScenarioState + private Context context; + + @Autowired + private RestTemplate restTemplate; + + @Autowired + private MockMvc mvc; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @SneakyThrows + public When request_to_the_router_is_sent() { + MockHttpServletResponse response = send(POST, "rest/submit", MAPPER.writeValueAsString(RequestBody.builder() + .command(context.getCommand()) + .routerHost(context.getRouterHost()) + .build())); + + context.withResponseStatus(response.getStatus()); + if (response.getStatus() == 200) { + String responseBody = response.getContentAsString(); + log.info("response body = {}", responseBody); + context.withSubmitResponseBody(MAPPER.readValue(responseBody, SubmitResponseBody.class)); + } + + return self(); + } + + @SneakyThrows + public When request_for_available_commands_is_sent() { + MockHttpServletResponse response = send(GET,"rest/commands/all", null); + if (response.getStatus() == 200) { + String responseBody = response.getContentAsString(); + log.info("response body = {}", responseBody); + context.withCommandResponseBody(MAPPER.readValue(responseBody, new TypeReference<Collection<Context.CommandGroup>>() {})); + } + return self(); + } + + @SneakyThrows + private MockHttpServletResponse send(HttpMethod method, String path, String body) { + MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders + .request(method, UriComponentsBuilder.newInstance().scheme("http").path(path).build().toUri()) + .contentType(APPLICATION_JSON); + + if (StringUtils.isNotBlank(body)) { + requestBuilder.content(body); + } + + LGUser user = context.getUser(); + if (user != null) { + requestBuilder.with(user(user)); + } + + MockHttpServletResponse response = mvc.perform(requestBuilder).andReturn().getResponse(); + context.withResponseStatus(response.getStatus()); + return response; + } + + @Getter + public static class RequestBody { + + private final Collection<Router> selectedRouters; + private final Command selectedCommand; + + @Builder + private RequestBody(String routerHost, String command) { + this.selectedRouters = singletonList(Router.withHost(routerHost)); + this.selectedCommand = Command.withCommand(command); + } + + @RequiredArgsConstructor + @Getter + public static class Router { + + private final String name; + + public static Router withHost(String hostName) { + return new Router(hostName); + } + } + + @RequiredArgsConstructor + @Getter + public static class Command { + + private final String value; + + public static Command withCommand(String command) { + return new Command(command); + } + } + } +} diff --git a/src/test/java/org/geant/lgservice/security/LGUserServiceTest.java b/src/test/java/org/geant/lgservice/security/LGUserServiceTest.java new file mode 100644 index 0000000000000000000000000000000000000000..50f5332788d2478f5a67520d0ef15bee9a0735e6 --- /dev/null +++ b/src/test/java/org/geant/lgservice/security/LGUserServiceTest.java @@ -0,0 +1,71 @@ +package org.geant.lgservice.security; + +import com.google.common.collect.ImmutableMap; +import org.junit.Before; +import org.junit.Test; +import org.springframework.security.saml.SAMLCredential; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.geant.lgservice.security.LGUser.Group.EXTERNAL; +import static org.geant.lgservice.security.LGUser.Group.INTERNAL; +import static org.geant.lgservice.security.SamlKeyMappings.Key.EMAIL; +import static org.geant.lgservice.security.SamlKeyMappings.Key.GIVEN_NAME; +import static org.geant.lgservice.security.SamlKeyMappings.Key.GROUPS; +import static org.geant.lgservice.security.SamlKeyMappings.Key.ID; +import static org.geant.lgservice.security.SamlKeyMappings.Key.SURNAME; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.when; + +public class LGUserServiceTest { + + SAMLCredential credential = mock(SAMLCredential.class); + + LGUserService subject = new LGUserService( + ImmutableMap.<SamlKeyMappings.Key, String>builder() + .put(ID, "uid") + .put(GIVEN_NAME, "givenName") + .put(SURNAME, "sn") + .put(EMAIL, "mail") + .put(GROUPS, "isMemberOf") + .build(), + ImmutableMap.<String, LGUser.Group>builder() + .put("internal", INTERNAL) + .put("external", EXTERNAL) + .build()); + + @Before + public void setup() { + reset(credential); + when(credential.getAttributeAsString("uid")).thenReturn("1"); + when(credential.getAttributeAsString("givenName")).thenReturn("barry"); + when(credential.getAttributeAsString("sn")).thenReturn("scott"); + when(credential.getAttributeAsString("mail")).thenReturn("barry.scott@geant.org"); + } + + @Test + public void loadUserWithMultipleGroups() { + when(credential.getAttributeAsStringArray("isMemberOf")) + .thenReturn(new String[]{"internal", "external", "ignore"}); + + LGUser result = (LGUser) subject.loadUserBySAML(credential); + assertThat(result.getFederatedUser()).isEqualTo("1"); + assertThat(result.getGivenName()).isEqualTo("barry"); + assertThat(result.getSurname()).isEqualTo("scott"); + assertThat(result.getMail()).isEqualTo("barry.scott@geant.org"); + assertThat(result.getAuthorities()).containsExactlyInAnyOrder(INTERNAL, EXTERNAL); + } + + @Test + public void loadUser() { + when(credential.getAttributeAsStringArray("isMemberOf")) + .thenReturn(new String[]{"external", "ignore"}); + + LGUser result = (LGUser) subject.loadUserBySAML(credential); + assertThat(result.getFederatedUser()).isEqualTo("1"); + assertThat(result.getGivenName()).isEqualTo("barry"); + assertThat(result.getSurname()).isEqualTo("scott"); + assertThat(result.getMail()).isEqualTo("barry.scott@geant.org"); + assertThat(result.getAuthorities()).containsExactlyInAnyOrder(EXTERNAL); + } +} diff --git a/src/test/java/org/geant/lgservice/services/LookingGlassServiceImplTest.java b/src/test/java/org/geant/lgservice/services/LookingGlassServiceImplTest.java deleted file mode 100644 index ba313f7892e7071784d0847afc444adc73aab999..0000000000000000000000000000000000000000 --- a/src/test/java/org/geant/lgservice/services/LookingGlassServiceImplTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package org.geant.lgservice.services; - -import org.assertj.core.api.Assertions; -import org.geant.lgservice.ecmr.CommandExecutor; -import org.geant.lgservice.pojos.Command; -import org.geant.lgservice.pojos.CommandOutput; -import org.geant.lgservice.pojos.Coordinates; -import org.geant.lgservice.pojos.Router; -import org.hibernate.result.Output; -import org.junit.Before; -import org.junit.Test; -import org.mockito.Mockito; - -import java.util.Collections; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.Mockito.*; - -public class LookingGlassServiceImplTest { - - private CommandExecutor commandExecutor = mock(CommandExecutor.class); - - private LookingGlassServiceImpl subject = new LookingGlassServiceImpl(null, commandExecutor); - - @Before - public void setup() { - reset(commandExecutor); - } - - @Test - public void testQuery() { - Router router = Router.builder() - .name("router.geant.org") - .build(); - - Command command = new Command(); - String arguments = "some args"; - - CommandOutput output = new CommandOutput(); - output.setRouterName(router.getName()); - - when(commandExecutor.execute(any(Router.class), any(Command.class), any(String.class), anyBoolean())).thenReturn(output); - - Map<String, CommandOutput> result = subject.submitQuery(Collections.singletonList(router), command, arguments, false); - - assertThat(result).hasSize(1); - assertThat(result).containsKey(router.getName()); - assertThat(result.get(router.getName())).isEqualTo(output); - } -} diff --git a/src/test/java/org/geant/lgservice/services/LookingGlassServiceTest.java b/src/test/java/org/geant/lgservice/services/LookingGlassServiceTest.java deleted file mode 100644 index 4669aad648d8c1b60982c7caa1a9544737bc45f5..0000000000000000000000000000000000000000 --- a/src/test/java/org/geant/lgservice/services/LookingGlassServiceTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.geant.lgservice.services; - -import static org.junit.Assert.assertEquals; - -import java.util.List; - -import org.geant.lgservice.pojos.Group; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.junit4.SpringRunner; - -@RunWith(SpringRunner.class) -@SpringBootTest -public class LookingGlassServiceTest { - - @Autowired - LookingGlassService fixture; - - @Test - public void test() { - List<Group> commandGroups = fixture.getAllCommands(null); - assertEquals("number of commands not correct", 1, commandGroups.size()); - assertEquals("group name not correct", "test-group", commandGroups.get(0).getName()); - assertEquals("command name not correct", "test-name", commandGroups.get(0).getCommands().get(0).getName()); - } - -} diff --git a/src/test/resources/__files/routers.json b/src/test/resources/__files/routers.json new file mode 100644 index 0000000000000000000000000000000000000000..c3e1d17320216e3e591da8824fdf8ecf79a5ffd7 --- /dev/null +++ b/src/test/resources/__files/routers.json @@ -0,0 +1,470 @@ +[ + { + "equipment name": "mx1.cbg.uk.geant.net", + "pop": { + "abbreviation": "lab", + "city": "Cambridge", + "country": "UK", + "country code": "UK", + "latitude": 0.0, + "longitude": 0.0, + "name": "DANTE Lab" + }, + "type": "INTERNAL" + }, + { + "equipment name": "mx2.tal.ee.geant.net", + "pop": { + "abbreviation": "tal", + "city": "Tallinn", + "country": "Estonia", + "country code": "EE", + "latitude": 59.434825, + "longitude": 24.71385, + "name": "Tallinn" + }, + "type": "CORE" + }, + { + "equipment name": "mx1.lon.uk.geant.net", + "pop": { + "abbreviation": "lon", + "city": "London", + "country": "England", + "country code": "UK", + "latitude": 51.498652777778, + "longitude": -0.015805555555556, + "name": "London" + }, + "type": "CORE" + }, + { + "equipment name": "mx1.pra.cz.geant.net", + "pop": { + "abbreviation": "pra", + "city": "Prague", + "country": "Czech Republic", + "country code": "CZ", + "latitude": 50.101847222222, + "longitude": 14.391738888889, + "name": "Prague" + }, + "type": "CORE" + }, + { + "equipment name": "mx1.sof.bg.geant.net", + "pop": { + "abbreviation": "sof", + "city": "Sofia", + "country": "Bulgaria", + "country code": "BG", + "latitude": 42.675758333333, + "longitude": 23.370986111111, + "name": "Sofia" + }, + "type": "CORE" + }, + { + "equipment name": "mx2.rig.lv.geant.net", + "pop": { + "abbreviation": "rig", + "city": "Riga", + "country": "Latvia", + "country code": "LV", + "latitude": 56.948344444444, + "longitude": 24.118, + "name": "Riga" + }, + "type": "CORE" + }, + { + "equipment name": "mx1.lon2.uk.geant.net", + "pop": { + "abbreviation": "lon2", + "city": "Slough", + "country": "UK", + "country code": "UK", + "latitude": 51.521986111111, + "longitude": 0.62331666666667, + "name": "London 2 (Slough)" + }, + "type": "CORE" + }, + { + "equipment name": "mx1.ham.de.geant.net", + "pop": { + "abbreviation": "ham", + "city": "Hamburg", + "country": "Germany", + "country code": "DE", + "latitude": 53.550902777778, + "longitude": 10.046486111111, + "name": "Hamburg" + }, + "type": "CORE" + }, + { + "equipment name": "mx1.poz.pl.geant.net", + "pop": { + "abbreviation": "poz", + "city": "Poznan", + "country": "Poland", + "country code": "PL", + "latitude": 52.411775, + "longitude": 16.917561111111, + "name": "Poznan" + }, + "type": "CORE" + }, + { + "equipment name": "mx1.ath2.gr.geant.net", + "pop": { + "abbreviation": "ath2", + "city": "Attiki", + "country": "Greece", + "country code": "GR", + "latitude": 37.98, + "longitude": 23.73, + "name": "Athens 2" + }, + "type": "CORE" + }, + { + "equipment name": "mx1.kau.lt.geant.net", + "pop": { + "abbreviation": "kau", + "city": "Kaunas", + "country": "Lithuania", + "country code": "LT", + "latitude": 54.940672222222, + "longitude": 24.018013888889, + "name": "Kaunas" + }, + "type": "CORE" + }, + { + "equipment name": "mx1.bud.hu.geant.net", + "pop": { + "abbreviation": "bud", + "city": "Budapest", + "country": "Hungary", + "country code": "HU", + "latitude": 47.517902777778, + "longitude": 19.055436111111, + "name": "Budapest" + }, + "type": "CORE" + }, + { + "equipment name": "mx2.bru.be.geant.net", + "pop": { + "abbreviation": "bru", + "city": "Brussels", + "country": "Belgium", + "country code": "BE", + "latitude": 50.857002777778, + "longitude": 4.4115694444444, + "name": "Brussels" + }, + "type": "CORE" + }, + { + "equipment name": "mx2.bra.sk.geant.net", + "pop": { + "abbreviation": "bra", + "city": "Bratislava", + "country": "Slovakia", + "country code": "SK", + "latitude": 48.119027777778, + "longitude": 17.0957, + "name": "Bratislava" + }, + "type": "CORE" + }, + { + "equipment name": "mx2.kau.lt.geant.net", + "pop": { + "abbreviation": "kau", + "city": "Kaunas", + "country": "Lithuania", + "country code": "LT", + "latitude": 54.940672222222, + "longitude": 24.018013888889, + "name": "Kaunas" + }, + "type": "CORE" + }, + { + "equipment name": "mx2.cbg.uk", + "pop": { + "abbreviation": "oc", + "city": "CAMBRIDGE", + "country": "UK", + "country code": "UK", + "latitude": 0.0, + "longitude": 0.0, + "name": "Cambridge OC" + }, + "type": "INTERNAL" + }, + { + "equipment name": "mx1.buc.ro.geant.net", + "pop": { + "abbreviation": "buc", + "city": "Bucharest", + "country": "Romania", + "country code": "RO", + "latitude": 44.444741666667, + "longitude": 26.096422222222, + "name": "Bucharest" + }, + "type": "CORE" + }, + { + "equipment name": "mx1.vie.at.geant.net", + "pop": { + "abbreviation": "vie", + "city": "Vienna", + "country": "Austria", + "country code": "AT", + "latitude": 48.268925, + "longitude": 16.410194444444, + "name": "Vienna" + }, + "type": "CORE" + }, + { + "equipment name": "mx1.mil2.it.geant.net", + "pop": { + "abbreviation": "mil2", + "city": "Milan", + "country": "Italy", + "country code": "IT", + "latitude": 45.475522222222, + "longitude": 9.1033194444444, + "name": "Milan 2 Caldera" + }, + "type": "CORE" + }, + { + "equipment name": "mx1.ams.nl.geant.net", + "pop": { + "abbreviation": "ams", + "city": "Amsterdam", + "country": "Netherlands", + "country code": "NL", + "latitude": 52.356363888889, + "longitude": 4.9529555555556, + "name": "Amsterdam" + }, + "type": "CORE" + }, + { + "equipment name": "sw2.am.office.geant.net", + "pop": { + "abbreviation": "am", + "city": "Amsterdam", + "country": "Netherlands", + "country code": "NL", + "latitude": 52.313305555556, + "longitude": 4.9491111111111, + "name": "Amsterdam GEANT Office" + }, + "type": "INTERNAL" + }, + { + "equipment name": "mx1.fra.de.geant.net", + "pop": { + "abbreviation": "fra", + "city": "Frankfurt", + "country": "Germany", + "country code": "DE", + "latitude": 50.120294444444, + "longitude": 8.7358444444444, + "name": "Frankfurt" + }, + "type": "CORE" + }, + { + "equipment name": "mx1.lis.pt.geant.net", + "pop": { + "abbreviation": "lis2", + "city": "Lisbon", + "country": "Portugal", + "country code": "PT", + "latitude": 38.759097222222, + "longitude": -9.1421944444444, + "name": "Lisbon 2" + }, + "type": "CORE" + }, + { + "equipment name": "mx2.lju.si.geant.net", + "pop": { + "abbreviation": "lju", + "city": "Ljubljana", + "country": "Slovenia", + "country code": "SI", + "latitude": 46.042366666667, + "longitude": 14.488719444444, + "name": "Ljubljana" + }, + "type": "CORE" + }, + { + "equipment name": "mx1.dub.ie.geant.net", + "pop": { + "abbreviation": "dub", + "city": "Dublin", + "country": "Ireland", + "country code": "IE", + "latitude": 53.291980555556, + "longitude": -6.4147333333333, + "name": "Dublin (City West)" + }, + "type": "CORE" + }, + { + "equipment name": "mx2.lis.pt.geant.net", + "pop": { + "abbreviation": "lis", + "city": "Lisbon", + "country": "Portugal", + "country code": "PT", + "latitude": 38.759097222222, + "longitude": -9.1421944444444, + "name": "Lisbon" + }, + "type": "CORE" + }, + { + "equipment name": "mx1.mad.es.geant.net", + "pop": { + "abbreviation": "mad", + "city": "Alcobendas - Madrid", + "country": "Spain", + "country code": "ES", + "latitude": 40.536477777778, + "longitude": -3.6488027777778, + "name": "Madrid" + }, + "type": "CORE" + }, + { + "equipment name": "mx1.tal.ee.geant.net", + "pop": { + "abbreviation": "tal", + "city": "Tallinn", + "country": "Estonia", + "country code": "EE", + "latitude": 59.434825, + "longitude": 24.71385, + "name": "Tallinn" + }, + "type": "CORE" + }, + { + "equipment name": "mx2.ath.gr.geant.net", + "pop": { + "abbreviation": "ath", + "city": "Athens", + "country": "Greece", + "country code": "GR", + "latitude": 37.973086111111, + "longitude": 23.745555555556, + "name": "Athens" + }, + "type": "CORE" + }, + { + "equipment name": "mx4 - Camb Lab", + "pop": { + "abbreviation": "lab", + "city": "Cambridge", + "country": "UK", + "country code": "UK", + "latitude": 0.0, + "longitude": 0.0, + "name": "DANTE Lab" + }, + "type": "INTERNAL" + }, + { + "equipment name": "mx1.par.fr.geant.net", + "pop": { + "abbreviation": "par", + "city": "Paris", + "country": "France", + "country code": "FR", + "latitude": 48.904630555556, + "longitude": 2.3713555555556, + "name": "Paris" + }, + "type": "CORE" + }, + { + "equipment name": "mx1.dub2.ie.geant.net", + "pop": { + "abbreviation": "dub2", + "city": "Dublin 12", + "country": "Ireland", + "country code": "IE", + "latitude": 53.33425, + "longitude": -6.3655444444444, + "name": "Dublin 2 - Parkwest" + }, + "type": "CORE" + }, + { + "equipment name": "sw3.am.office.geant.net", + "pop": { + "abbreviation": "am", + "city": "Amsterdam", + "country": "Netherlands", + "country code": "NL", + "latitude": 52.313305555556, + "longitude": 4.9491111111111, + "name": "Amsterdam GEANT Office" + }, + "type": "INTERNAL" + }, + { + "equipment name": "mx2.zag.hr.geant.net", + "pop": { + "abbreviation": "zag", + "city": "Zagreb", + "country": "Crotia", + "country code": "HR", + "latitude": 45.792030555556, + "longitude": 15.969494444444, + "name": "Zagreb" + }, + "type": "CORE" + }, + { + "equipment name": "mx1.mar.fr.geant.net", + "pop": { + "abbreviation": "mar", + "city": "Marseille", + "country": "France", + "country code": "FR", + "latitude": 43.311027777778, + "longitude": 5.3738888888889, + "name": "Marseille" + }, + "type": "CORE" + }, + { + "equipment name": "mx1.gen.ch.geant.net", + "pop": { + "abbreviation": "gen", + "city": "Geneva", + "country": "Switzerland", + "country code": "CH", + "latitude": 46.232194444444, + "longitude": 6.0457638888889, + "name": "Geneva" + }, + "type": "CORE" + } +] \ No newline at end of file diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 5bddf85b14c909a998a8a58bd624eedd5ffafd07..4ff7a719f4e6ea3c6b147748b34a8c8113c8263a 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -10,24 +10,6 @@ configuration: security: saml2: metadataURL: "https://login.terena.org/wayf/saml2/idp/metadata" -server: - port: 8009 - ssl: - enabled: true -spring: - datasource: - driverClassName: com.mysql.jdbc.Driver - password: opsro - url: "jdbc:mysql://test-opsdb01.geant.net:3306/opsdb" - username: opsro - jpa: - generate-ddl: true - hibernate.ddl-auto: update - properties: - hibernate: - dialect: org.hibernate.dialect.MySQL5Dialect - show-sql: false - show-sql: false saml: metadata-url: https://login.terena.org/wayf/saml2/idp/metadata entity-name: https://test-lg.geant.net/saml @@ -35,3 +17,12 @@ saml: key-store: classpath:saml/keystore.jks key-alias: spring key-store-password: secret + key-mappings: + id: "id" + given-name: "givenName" + surname: "surname" + email: "mail" + groups: "isMemberOf" + group-mappings: + INTERNAL: "GEANT Staff:All" + EXTERNAL: "GEANT_CO:GEANT Services:NREN Svc Mgmt:GEANT NRENs:members_GEANT NRENs" diff --git a/src/test/resources/commands_external.xml b/src/test/resources/commands_external.xml new file mode 100644 index 0000000000000000000000000000000000000000..c955898fc7b6c9c0251af4d73723c329f836c7b1 --- /dev/null +++ b/src/test/resources/commands_external.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- List of Commands, here name element contains the command name that is + visible on UI and value contains actual command. --> +<commands> + <group name="public-group"> + <command> + <name>public-command</name> + <value>public-command</value> + <description>public-command</description> + <requiresParams>true</requiresParams> + </command> + </group> + <group name="external-group"> + <command> + <name>external-command</name> + <value>external-command</value> + <description>external-command</description> + <requiresParams>true</requiresParams> + </command> + </group> +</commands> \ No newline at end of file diff --git a/src/test/resources/commands_internal.xml b/src/test/resources/commands_internal.xml new file mode 100644 index 0000000000000000000000000000000000000000..f1ef017e54a71a6ffa5e8766bc4abf51650fc32b --- /dev/null +++ b/src/test/resources/commands_internal.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- List of Commands, here name element contains the command name that is + visible on UI and value contains actual command. --> +<commands> + <group name="public-group"> + <command> + <name>public-command</name> + <value>public-command</value> + <description>public-command</description> + <requiresParams>true</requiresParams> + </command> + </group> + <group name="external-group"> + <command> + <name>external-command</name> + <value>external-command</value> + <description>external-command</description> + <requiresParams>true</requiresParams> + </command> + </group> + <group name="internal-group"> + <command> + <name>internal-command</name> + <value>internal-command</value> + <description>internal-command</description> + <requiresParams>true</requiresParams> + </command> + </group> +</commands> \ No newline at end of file diff --git a/src/test/resources/commands_public.xml b/src/test/resources/commands_public.xml index 8b785de5ab5590d7731b24c76472c263060f9888..70b1e69507f4ca895604c968a921ccb81ea9d1d9 100644 --- a/src/test/resources/commands_public.xml +++ b/src/test/resources/commands_public.xml @@ -1,13 +1,13 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!-- List of Commands, here name element contains the command name that is - visible on UI and value contains actual command. --> -<commands> - <group name="test-group"> - <command> - <name>test-name</name> - <value>test-value</value> - <description>test-description</description> - <requiresParams>true</requiresParams> - </command> - </group> +<?xml version="1.0" encoding="UTF-8"?> +<!-- List of Commands, here name element contains the command name that is + visible on UI and value contains actual command. --> +<commands> + <group name="public-group"> + <command> + <name>public-command</name> + <value>public-command</value> + <description>public-command</description> + <requiresParams>true</requiresParams> + </command> + </group> </commands> \ No newline at end of file