diff --git a/pom.xml b/pom.xml index a3cfe9aa01..87c4ac2c5a 100644 --- a/pom.xml +++ b/pom.xml @@ -70,6 +70,8 @@ 4.5.13 5.4.1 3.9.9 + 9.15.2 + 1.69 1.7.30 2.8.6 3.0.1 @@ -140,6 +142,16 @@ vertx-web-client ${io.vertx.web.version} + + com.nimbusds + nimbus-jose-jwt + ${com.nimbusds.jose.jwt.version} + + + org.bouncycastle + bcprov-jdk15on + ${org.bouncycastle.version} + org.slf4j slf4j-jdk14 diff --git a/src/main/java/io/cryostat/Cryostat.java b/src/main/java/io/cryostat/Cryostat.java index 9839272acd..7495fe8380 100644 --- a/src/main/java/io/cryostat/Cryostat.java +++ b/src/main/java/io/cryostat/Cryostat.java @@ -37,6 +37,7 @@ */ package io.cryostat; +import java.security.Security; import java.util.concurrent.CompletableFuture; import javax.inject.Singleton; @@ -52,6 +53,7 @@ import io.cryostat.rules.RuleProcessor; import io.cryostat.rules.RuleRegistry; +import com.nimbusds.jose.crypto.bc.BouncyCastleProviderSingleton; import dagger.Component; class Cryostat { @@ -59,6 +61,8 @@ class Cryostat { public static void main(String[] args) throws Exception { CryostatCore.initialize(); + Security.addProvider(BouncyCastleProviderSingleton.getInstance()); + final Logger logger = Logger.INSTANCE; final Environment environment = new Environment(); diff --git a/src/main/java/io/cryostat/net/NetworkModule.java b/src/main/java/io/cryostat/net/NetworkModule.java index afb4c0db52..ddc36e335c 100644 --- a/src/main/java/io/cryostat/net/NetworkModule.java +++ b/src/main/java/io/cryostat/net/NetworkModule.java @@ -53,6 +53,7 @@ import io.cryostat.core.sys.FileSystem; import io.cryostat.core.tui.ClientWriter; import io.cryostat.net.reports.ReportsModule; +import io.cryostat.net.security.SecurityModule; import io.cryostat.net.web.WebModule; import com.github.benmanes.caffeine.cache.Scheduler; @@ -71,6 +72,7 @@ includes = { WebModule.class, ReportsModule.class, + SecurityModule.class, }) public abstract class NetworkModule { diff --git a/src/main/java/io/cryostat/net/SslConfiguration.java b/src/main/java/io/cryostat/net/SslConfiguration.java index 795518ead8..26d081c001 100644 --- a/src/main/java/io/cryostat/net/SslConfiguration.java +++ b/src/main/java/io/cryostat/net/SslConfiguration.java @@ -49,7 +49,7 @@ import io.vertx.core.net.PfxOptions; import org.apache.commons.lang3.tuple.Pair; -class SslConfiguration { +public class SslConfiguration { private final Environment env; private final FileSystem fs; private final Logger logger; @@ -67,6 +67,12 @@ class SslConfiguration { this.fs = fs; this.logger = logger; + if (env.hasEnv("CRYOSTAT_DISABLE_SSL")) { + strategy = new NoSslStrategy(); + logger.info("Selected NoSSL strategy"); + return; + } + { Path path = obtainKeyStorePathIfSpecified(); if (path != null) { @@ -199,11 +205,11 @@ Pair discoverKeyCertPathPairInDefaultLocations() { return Pair.of(key, cert); } - HttpServerOptions applyToHttpServerOptions(HttpServerOptions options) { + public HttpServerOptions applyToHttpServerOptions(HttpServerOptions options) { return strategy.applyToHttpServerOptions(options); } - boolean enabled() { + public boolean enabled() { return strategy.enabled(); } diff --git a/src/main/java/io/cryostat/net/security/SecurityModule.java b/src/main/java/io/cryostat/net/security/SecurityModule.java new file mode 100644 index 0000000000..7a96eb2406 --- /dev/null +++ b/src/main/java/io/cryostat/net/security/SecurityModule.java @@ -0,0 +1,48 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.cryostat.net.security; + +import io.cryostat.net.security.jwt.JwtModule; + +import dagger.Module; + +@Module( + includes = { + JwtModule.class, + }) +public abstract class SecurityModule {} diff --git a/src/main/java/io/cryostat/net/security/jwt/AssetJwtHelper.java b/src/main/java/io/cryostat/net/security/jwt/AssetJwtHelper.java new file mode 100644 index 0000000000..acdaada053 --- /dev/null +++ b/src/main/java/io/cryostat/net/security/jwt/AssetJwtHelper.java @@ -0,0 +1,155 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.cryostat.net.security.jwt; + +import java.net.MalformedURLException; +import java.net.SocketException; +import java.net.URISyntaxException; +import java.net.UnknownHostException; +import java.text.ParseException; +import java.time.Instant; +import java.util.Date; +import java.util.HashSet; +import java.util.Set; + +import io.cryostat.core.log.Logger; +import io.cryostat.net.web.WebServer; + +import com.nimbusds.jose.EncryptionMethod; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWEAlgorithm; +import com.nimbusds.jose.JWEDecrypter; +import com.nimbusds.jose.JWEEncrypter; +import com.nimbusds.jose.JWEHeader; +import com.nimbusds.jose.JWEObject; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.Payload; +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.nimbusds.jwt.proc.BadJWTException; +import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier; +import dagger.Lazy; + +public class AssetJwtHelper { + + public static final String RESOURCE_CLAIM = "resource"; + public static final String JMXAUTH_CLAIM = "jmxauth"; + + private final Lazy webServer; + private final JWSSigner signer; + private final JWSVerifier verifier; + private final JWEEncrypter encrypter; + private final JWEDecrypter decrypter; + private final boolean subjectRequired; + + AssetJwtHelper( + Lazy webServer, + JWSSigner signer, + JWSVerifier verifier, + JWEEncrypter encrypter, + JWEDecrypter decrypter, + boolean subjectRequired, + Logger logger) { + this.webServer = webServer; + this.signer = signer; + this.verifier = verifier; + this.encrypter = encrypter; + this.decrypter = decrypter; + this.subjectRequired = subjectRequired; + } + + public String createAssetDownloadJwt(String subject, String resource, String jmxauth) + throws JOSEException, SocketException, UnknownHostException, URISyntaxException, + MalformedURLException { + String issuer = webServer.get().getHostUrl().toString(); + Date now = Date.from(Instant.now()); + Date expiry = Date.from(now.toInstant().plusSeconds(120)); + JWTClaimsSet claims = + new JWTClaimsSet.Builder() + .issuer(issuer) + .audience(issuer) + .issueTime(now) + .notBeforeTime(now) + .expirationTime(expiry) + .subject(subject) + .claim(RESOURCE_CLAIM, resource) + .claim(JMXAUTH_CLAIM, jmxauth) + .build(); + + SignedJWT jwt = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.HS256).build(), claims); + jwt.sign(signer); + + JWEHeader header = + new JWEHeader.Builder(JWEAlgorithm.DIR, EncryptionMethod.A256GCM) + .contentType("JWT") + .build(); + JWEObject jwe = new JWEObject(header, new Payload(jwt)); + jwe.encrypt(encrypter); + + return jwe.serialize(); + } + + public JWT parseAssetDownloadJwt(String rawToken) + throws ParseException, JOSEException, BadJWTException, SocketException, + UnknownHostException, URISyntaxException, MalformedURLException { + JWEObject jwe = JWEObject.parse(rawToken); + jwe.decrypt(decrypter); + + SignedJWT jwt = jwe.getPayload().toSignedJWT(); + jwt.verify(verifier); + + // TODO extract this claims verifier + // TODO add a SecurityContext + String cryostatUri = webServer.get().getHostUrl().toString(); + JWTClaimsSet exactMatchClaims = + new JWTClaimsSet.Builder().issuer(cryostatUri).audience(cryostatUri).build(); + Set requiredClaimNames = + new HashSet<>(Set.of("exp", "nbf", "iat", "iss", "aud", RESOURCE_CLAIM)); + if (subjectRequired) { + requiredClaimNames.add("sub"); + } + new DefaultJWTClaimsVerifier<>(cryostatUri, exactMatchClaims, requiredClaimNames) + .verify(jwt.getJWTClaimsSet(), null); + + return jwt; + } +} diff --git a/src/main/java/io/cryostat/net/security/jwt/JwtModule.java b/src/main/java/io/cryostat/net/security/jwt/JwtModule.java new file mode 100644 index 0000000000..ca81da0f79 --- /dev/null +++ b/src/main/java/io/cryostat/net/security/jwt/JwtModule.java @@ -0,0 +1,140 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.cryostat.net.security.jwt; + +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.inject.Singleton; + +import io.cryostat.core.log.Logger; +import io.cryostat.core.sys.Environment; +import io.cryostat.net.AuthManager; +import io.cryostat.net.AuthenticationScheme; +import io.cryostat.net.web.WebServer; + +import com.nimbusds.jose.JWEDecrypter; +import com.nimbusds.jose.JWEEncrypter; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.crypto.DirectDecrypter; +import com.nimbusds.jose.crypto.DirectEncrypter; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jose.crypto.MACVerifier; +import dagger.Lazy; +import dagger.Module; +import dagger.Provides; + +@Module +public abstract class JwtModule { + + @Provides + @Singleton + static AssetJwtHelper provideJwtFactory( + Lazy webServer, + JWSSigner signer, + JWSVerifier verifier, + JWEEncrypter encrypter, + JWEDecrypter decrypter, + AuthManager auth, + Logger logger) { + try { + return new AssetJwtHelper( + webServer, + signer, + verifier, + encrypter, + decrypter, + !AuthenticationScheme.NONE.equals(auth.getScheme()), + logger); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Provides + @Singleton + static SecretKey provideSecretKey() { + try { + KeyGenerator generator = KeyGenerator.getInstance("AES"); + generator.init(256); + return generator.generateKey(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Provides + @Singleton + static JWSSigner provideJwsSigner(SecretKey key) { + try { + return new MACSigner(key); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Provides + @Singleton + static JWSVerifier provideJwsVerifier(SecretKey key) { + try { + return new MACVerifier(key); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Provides + @Singleton + static JWEEncrypter provideJweEncrypter(Environment env, SecretKey key, Logger logger) { + try { + return new DirectEncrypter(key); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Provides + @Singleton + static JWEDecrypter provideJweDecrypter(Environment env, SecretKey key) { + try { + return new DirectDecrypter(key); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/io/cryostat/net/web/WebServer.java b/src/main/java/io/cryostat/net/web/WebServer.java index cd19f61173..0d9b91b9c4 100644 --- a/src/main/java/io/cryostat/net/web/WebServer.java +++ b/src/main/java/io/cryostat/net/web/WebServer.java @@ -47,6 +47,7 @@ import java.time.Duration; import java.time.Instant; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; @@ -64,6 +65,7 @@ import io.cryostat.net.web.http.api.ApiData; import io.cryostat.net.web.http.api.ApiMeta; import io.cryostat.net.web.http.api.ApiResponse; +import io.cryostat.net.web.http.api.ApiVersion; import io.cryostat.net.web.http.api.v2.ApiException; import io.cryostat.util.HttpStatusCodeIdentifier; @@ -292,48 +294,41 @@ URI getHostUri() throws SocketException, UnknownHostException, URISyntaxExceptio .normalize(); } + // FIXME this has an implicit dependency on the RecordingGetHandler path public String getArchivedDownloadURL(String recordingName) throws UnknownHostException, URISyntaxException, SocketException { - return new URIBuilder(getHostUri()) - .setScheme(server.isSsl() ? "https" : "http") - .setPathSegments("api", "v1", "recordings", recordingName) - .build() - .normalize() - .toString(); + return getAssetDownloadURL(ApiVersion.V1, "recordings", recordingName); } + // FIXME this has a an implicit dependency on the TargetRecordingGetHandler path public String getDownloadURL(JFRConnection connection, String recordingName) throws URISyntaxException, IOException { - return new URIBuilder(getHostUri()) - .setScheme(server.isSsl() ? "https" : "http") - .setPathSegments( - "api", - "v1", - "targets", - getTargetId(connection), - "recordings", - recordingName) - .build() - .normalize() - .toString(); + return getAssetDownloadURL( + ApiVersion.V1, "targets", getTargetId(connection), "recordings", recordingName); } + // FIXME this has a an implicit dependency on the ReportGetHandler path public String getArchivedReportURL(String recordingName) throws SocketException, UnknownHostException, URISyntaxException { - return new URIBuilder(getHostUri()) - .setScheme(server.isSsl() ? "https" : "http") - .setPathSegments("api", "v1", "reports", recordingName) - .build() - .normalize() - .toString(); + return getAssetDownloadURL(ApiVersion.V1, "reports", recordingName); } + // FIXME this has a an implicit dependency on the TargetReportGetHandler path public String getReportURL(JFRConnection connection, String recordingName) throws URISyntaxException, IOException { + return getAssetDownloadURL( + ApiVersion.V1, "targets", getTargetId(connection), "reports", recordingName); + } + + public String getAssetDownloadURL(ApiVersion apiVersion, String... pathSegments) + throws SocketException, UnknownHostException, URISyntaxException { + List segments = new ArrayList<>(); + segments.add("api"); + segments.add(apiVersion.getVersionString()); + segments.addAll(Arrays.asList(pathSegments)); return new URIBuilder(getHostUri()) .setScheme(server.isSsl() ? "https" : "http") - .setPathSegments( - "api", "v1", "targets", getTargetId(connection), "reports", recordingName) + .setPathSegments(segments) .build() .normalize() .toString(); diff --git a/src/main/java/io/cryostat/net/web/http/api/beta/AbstractJwtConsumingHandler.java b/src/main/java/io/cryostat/net/web/http/api/beta/AbstractJwtConsumingHandler.java new file mode 100644 index 0000000000..6735cc630b --- /dev/null +++ b/src/main/java/io/cryostat/net/web/http/api/beta/AbstractJwtConsumingHandler.java @@ -0,0 +1,209 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.cryostat.net.web.http.api.beta; + +import java.net.MalformedURLException; +import java.net.SocketException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; +import java.rmi.ConnectIOException; +import java.text.ParseException; +import java.util.Base64; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.regex.Matcher; + +import javax.security.sasl.SaslException; + +import org.openjdk.jmc.rjmx.ConnectionException; + +import io.cryostat.core.log.Logger; +import io.cryostat.core.net.Credentials; +import io.cryostat.net.AuthManager; +import io.cryostat.net.ConnectionDescriptor; +import io.cryostat.net.security.jwt.AssetJwtHelper; +import io.cryostat.net.web.WebServer; +import io.cryostat.net.web.http.AbstractAuthenticatedRequestHandler; +import io.cryostat.net.web.http.RequestHandler; +import io.cryostat.net.web.http.api.v2.ApiException; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.proc.BadJWTException; +import dagger.Lazy; +import io.vertx.ext.web.RoutingContext; +import org.apache.commons.lang3.exception.ExceptionUtils; + +abstract class AbstractJwtConsumingHandler implements RequestHandler { + + protected final AuthManager auth; + protected final AssetJwtHelper jwt; + protected final Lazy webServer; + protected final Logger logger; + + protected AbstractJwtConsumingHandler( + AuthManager auth, AssetJwtHelper jwt, Lazy webServer, Logger logger) { + this.auth = auth; + this.jwt = jwt; + this.webServer = webServer; + this.logger = logger; + } + + abstract void handleWithValidJwt(RoutingContext ctx, JWT jwt) throws Exception; + + @Override + public final void handle(RoutingContext ctx) { + try { + JWT jwt = validateJwt(ctx); + handleWithValidJwt(ctx, jwt); + } catch (ConnectionException e) { + Throwable cause = e.getCause(); + if (cause instanceof SecurityException || cause instanceof SaslException) { + ctx.response() + .putHeader( + AbstractAuthenticatedRequestHandler.JMX_AUTHENTICATE_HEADER, + "Basic"); + throw new ApiException(427, "JMX Authentication Failure", e); + } + Throwable rootCause = ExceptionUtils.getRootCause(e); + if (rootCause instanceof ConnectIOException) { + throw new ApiException(502, "Target SSL Untrusted", e); + } + if (rootCause instanceof UnknownHostException) { + throw new ApiException(404, "Target Not Found", e); + } + throw new ApiException(500, e); + } catch (Exception e) { + if (e instanceof ApiException) { + throw (ApiException) e; + } + throw new ApiException(500, e); + } + } + + private JWT validateJwt(RoutingContext ctx) + throws ParseException, JOSEException, SocketException, UnknownHostException, + URISyntaxException, MalformedURLException { + String token = ctx.queryParams().get("token"); + JWT parsed; + try { + parsed = jwt.parseAssetDownloadJwt(token); + } catch (BadJWTException e) { + throw new ApiException(401, e); + } + + URL hostUrl = webServer.get().getHostUrl(); + URI requestUri = new URI(ctx.request().absoluteURI()); + URI fullRequestUri = + new URI(hostUrl.getProtocol(), hostUrl.getAuthority(), null, null, null) + .resolve(requestUri.getRawPath()); + URI resourceClaim; + try { + resourceClaim = + new URI(parsed.getJWTClaimsSet().getStringClaim(AssetJwtHelper.RESOURCE_CLAIM)); + } catch (URISyntaxException use) { + throw new ApiException(401, use); + } + if (!Objects.equals(fullRequestUri, resourceClaim)) { + throw new ApiException(401, "Token resource claim does not match requested resource"); + } + + try { + String subject = parsed.getJWTClaimsSet().getSubject(); + if (!auth.validateHttpHeader(() -> subject, resourceActions()).get()) { + throw new ApiException(401, "Token subject has insufficient permissions"); + } + } catch (ExecutionException | InterruptedException e) { + throw new ApiException(401, "Token subject permissions could not be determined"); + } + + return parsed; + } + + protected ConnectionDescriptor getConnectionDescriptorFromJwt(RoutingContext ctx, JWT jwt) + throws ParseException { + String targetId = ctx.pathParam("targetId"); + // TODO inject the CredentialsManager here to check for stored credentials + Credentials credentials = null; + String jmxauth = jwt.getJWTClaimsSet().getStringClaim(AssetJwtHelper.JMXAUTH_CLAIM); + if (jmxauth != null) { + String c; + try { + Matcher m = + AbstractAuthenticatedRequestHandler.AUTH_HEADER_PATTERN.matcher(jmxauth); + if (!m.find()) { + throw new ApiException( + 427, + String.format("Invalid %s claim format", AssetJwtHelper.JMXAUTH_CLAIM)); + } + String t = m.group("type"); + if (!"basic".equals(t.toLowerCase())) { + throw new ApiException( + 427, + String.format( + "Unacceptable %s credentials type", + AssetJwtHelper.JMXAUTH_CLAIM)); + } + c = + new String( + Base64.getUrlDecoder().decode(m.group("credentials")), + StandardCharsets.UTF_8); + } catch (IllegalArgumentException iae) { + throw new ApiException( + 427, + String.format( + "%s claim credentials do not appear to be Base64-encoded", + AssetJwtHelper.JMXAUTH_CLAIM), + iae); + } + String[] parts = c.split(":"); + if (parts.length != 2) { + throw new ApiException( + 427, + String.format( + "Unrecognized %s claim credential format", + AssetJwtHelper.JMXAUTH_CLAIM)); + } + credentials = new Credentials(parts[0], parts[1]); + } + return new ConnectionDescriptor(targetId, credentials); + } +} diff --git a/src/main/java/io/cryostat/net/web/http/api/beta/AuthTokenPostBodyHandler.java b/src/main/java/io/cryostat/net/web/http/api/beta/AuthTokenPostBodyHandler.java new file mode 100644 index 0000000000..be01b13ab4 --- /dev/null +++ b/src/main/java/io/cryostat/net/web/http/api/beta/AuthTokenPostBodyHandler.java @@ -0,0 +1,91 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.cryostat.net.web.http.api.beta; + +import java.util.Set; + +import javax.inject.Inject; + +import io.cryostat.net.AuthManager; +import io.cryostat.net.security.ResourceAction; +import io.cryostat.net.web.http.AbstractAuthenticatedRequestHandler; +import io.cryostat.net.web.http.api.ApiVersion; + +import io.vertx.core.http.HttpMethod; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.BodyHandler; + +class AuthTokenPostBodyHandler extends AbstractAuthenticatedRequestHandler { + + static final BodyHandler BODY_HANDLER = BodyHandler.create(true); + + @Inject + AuthTokenPostBodyHandler(AuthManager auth) { + super(auth); + } + + @Override + public int getPriority() { + return DEFAULT_PRIORITY - 1; + } + + @Override + public ApiVersion apiVersion() { + return ApiVersion.BETA; + } + + @Override + public HttpMethod httpMethod() { + return HttpMethod.POST; + } + + @Override + public Set resourceActions() { + return ResourceAction.NONE; + } + + @Override + public String path() { + return basePath() + AuthTokenPostHandler.PATH; + } + + @Override + public void handleAuthenticated(RoutingContext ctx) throws Exception { + BODY_HANDLER.handle(ctx); + } +} diff --git a/src/main/java/io/cryostat/net/web/http/api/beta/AuthTokenPostHandler.java b/src/main/java/io/cryostat/net/web/http/api/beta/AuthTokenPostHandler.java new file mode 100644 index 0000000000..e2f1be70bd --- /dev/null +++ b/src/main/java/io/cryostat/net/web/http/api/beta/AuthTokenPostHandler.java @@ -0,0 +1,151 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.cryostat.net.web.http.api.beta; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Map; +import java.util.Set; + +import javax.inject.Inject; + +import io.cryostat.core.log.Logger; +import io.cryostat.net.AuthManager; +import io.cryostat.net.security.ResourceAction; +import io.cryostat.net.security.jwt.AssetJwtHelper; +import io.cryostat.net.web.WebServer; +import io.cryostat.net.web.http.AbstractAuthenticatedRequestHandler; +import io.cryostat.net.web.http.HttpMimeType; +import io.cryostat.net.web.http.api.ApiVersion; +import io.cryostat.net.web.http.api.v2.AbstractV2RequestHandler; +import io.cryostat.net.web.http.api.v2.ApiException; +import io.cryostat.net.web.http.api.v2.IntermediateResponse; +import io.cryostat.net.web.http.api.v2.RequestParameters; + +import com.google.gson.Gson; +import dagger.Lazy; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpMethod; +import org.apache.http.client.utils.URIBuilder; + +class AuthTokenPostHandler extends AbstractV2RequestHandler> { + + static final String PATH = "auth/token"; + + private final AssetJwtHelper jwt; + private final Lazy webServer; + + @Inject + AuthTokenPostHandler( + AuthManager auth, + Gson gson, + AssetJwtHelper jwt, + Lazy webServer, + Logger logger) { + super(auth, gson); + this.jwt = jwt; + this.webServer = webServer; + } + + @Override + public ApiVersion apiVersion() { + return ApiVersion.BETA; + } + + @Override + public String path() { + return basePath() + PATH; + } + + @Override + public HttpMethod httpMethod() { + return HttpMethod.POST; + } + + @Override + public Set resourceActions() { + return ResourceAction.NONE; + } + + @Override + public boolean requiresAuthentication() { + return true; + } + + @Override + public HttpMimeType mimeType() { + return HttpMimeType.JSON; + } + + @Override + public IntermediateResponse> handle(RequestParameters requestParams) + throws Exception { + String resource = requestParams.getFormAttributes().get(AssetJwtHelper.RESOURCE_CLAIM); + if (resource == null) { + throw new ApiException( + 400, + String.format( + "\"%s\" form attribute is required", AssetJwtHelper.RESOURCE_CLAIM)); + } + String resourcePrefix = webServer.get().getHostUrl().toString(); + URI resourceUri; + try { + resourceUri = new URI(resource); + } catch (URISyntaxException use) { + throw new ApiException(400, use); + } + if (resourceUri.isAbsolute() && !resource.startsWith(resourcePrefix)) { + throw new ApiException( + 400, String.format("\"%s\" URL is invalid", AssetJwtHelper.RESOURCE_CLAIM)); + } + + String authzHeader = requestParams.getHeaders().get(HttpHeaders.AUTHORIZATION); + String jmxauth = + requestParams + .getHeaders() + .get(AbstractAuthenticatedRequestHandler.JMX_AUTHORIZATION_HEADER); + String token = jwt.createAssetDownloadJwt(authzHeader, resource, jmxauth); + try { + URI finalUri = new URIBuilder(resourceUri).setParameter("token", token).build(); + return new IntermediateResponse>() + .body(Map.of("resourceUrl", finalUri.toString())); + } catch (URISyntaxException use) { + throw new ApiException(400, use); + } + } +} diff --git a/src/main/java/io/cryostat/net/web/http/api/beta/HttpApiBetaModule.java b/src/main/java/io/cryostat/net/web/http/api/beta/HttpApiBetaModule.java index 0d39ccc928..a447e73580 100644 --- a/src/main/java/io/cryostat/net/web/http/api/beta/HttpApiBetaModule.java +++ b/src/main/java/io/cryostat/net/web/http/api/beta/HttpApiBetaModule.java @@ -48,4 +48,32 @@ public abstract class HttpApiBetaModule { @Binds @IntoSet abstract RequestHandler bindDiscoveryGetHandler(DiscoveryGetHandler handler); + + @Binds + @IntoSet + abstract RequestHandler bindAuthTokenPostHandler(AuthTokenPostHandler handler); + + @Binds + @IntoSet + abstract RequestHandler bindAuthTokenPostBodyHandler(AuthTokenPostBodyHandler handler); + + @Binds + @IntoSet + abstract RequestHandler bindTargetRecordingGetHandler(TargetRecordingGetHandler handler); + + @Binds + @IntoSet + abstract RequestHandler bindTargetReportGetHandler(TargetReportGetHandler handler); + + @Binds + @IntoSet + abstract RequestHandler bindTargetTemplateGetHandler(TargetTemplateGetHandler handler); + + @Binds + @IntoSet + abstract RequestHandler bindRecordingGetHandler(RecordingGetHandler handler); + + @Binds + @IntoSet + abstract RequestHandler bindReportGetHandler(ReportGetHandler handler); } diff --git a/src/main/java/io/cryostat/net/web/http/api/beta/RecordingGetHandler.java b/src/main/java/io/cryostat/net/web/http/api/beta/RecordingGetHandler.java new file mode 100644 index 0000000000..cf65ad6357 --- /dev/null +++ b/src/main/java/io/cryostat/net/web/http/api/beta/RecordingGetHandler.java @@ -0,0 +1,126 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.cryostat.net.web.http.api.beta; + +import java.nio.file.Path; +import java.util.EnumSet; +import java.util.Set; +import java.util.concurrent.ExecutionException; + +import javax.inject.Inject; + +import io.cryostat.core.log.Logger; +import io.cryostat.net.AuthManager; +import io.cryostat.net.security.ResourceAction; +import io.cryostat.net.security.jwt.AssetJwtHelper; +import io.cryostat.net.web.WebServer; +import io.cryostat.net.web.http.HttpMimeType; +import io.cryostat.net.web.http.api.ApiVersion; +import io.cryostat.recordings.RecordingArchiveHelper; +import io.cryostat.recordings.RecordingNotFoundException; + +import com.nimbusds.jwt.JWT; +import dagger.Lazy; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpMethod; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.impl.HttpStatusException; + +class RecordingGetHandler extends AbstractJwtConsumingHandler { + + private final RecordingArchiveHelper recordingArchiveHelper; + + @Inject + RecordingGetHandler( + AuthManager auth, + AssetJwtHelper jwtFactory, + Lazy webServer, + RecordingArchiveHelper recordingArchiveHelper, + Logger logger) { + super(auth, jwtFactory, webServer, logger); + this.recordingArchiveHelper = recordingArchiveHelper; + } + + @Override + public ApiVersion apiVersion() { + return ApiVersion.BETA; + } + + @Override + public HttpMethod httpMethod() { + return HttpMethod.GET; + } + + @Override + public Set resourceActions() { + return EnumSet.of(ResourceAction.READ_RECORDING); + } + + @Override + public String path() { + return basePath() + "recordings/:recordingName"; + } + + @Override + public boolean isAsync() { + return true; + } + + @Override + public void handleWithValidJwt(RoutingContext ctx, JWT jwt) throws Exception { + String recordingName = ctx.pathParam("recordingName"); + try { + Path archivedRecording = recordingArchiveHelper.getRecordingPath(recordingName).get(); + ctx.response() + .putHeader( + HttpHeaders.CONTENT_DISPOSITION, + String.format("attachment; filename=\"%s\"", recordingName)); + ctx.response().putHeader(HttpHeaders.CONTENT_TYPE, HttpMimeType.OCTET_STREAM.mime()); + ctx.response() + .putHeader( + HttpHeaders.CONTENT_LENGTH, + Long.toString(archivedRecording.toFile().length())); + ctx.response().sendFile(archivedRecording.toAbsolutePath().toString()); + } catch (ExecutionException e) { + if (e.getCause() instanceof RecordingNotFoundException) { + throw new HttpStatusException(404, e.getMessage(), e); + } + throw e; + } + } +} diff --git a/src/main/java/io/cryostat/net/web/http/api/beta/ReportGetHandler.java b/src/main/java/io/cryostat/net/web/http/api/beta/ReportGetHandler.java new file mode 100644 index 0000000000..e2255393ea --- /dev/null +++ b/src/main/java/io/cryostat/net/web/http/api/beta/ReportGetHandler.java @@ -0,0 +1,131 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.cryostat.net.web.http.api.beta; + +import java.nio.file.Path; +import java.util.EnumSet; +import java.util.Set; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; + +import javax.inject.Inject; + +import io.cryostat.core.log.Logger; +import io.cryostat.net.AuthManager; +import io.cryostat.net.reports.ReportService; +import io.cryostat.net.security.ResourceAction; +import io.cryostat.net.security.jwt.AssetJwtHelper; +import io.cryostat.net.web.WebServer; +import io.cryostat.net.web.http.HttpMimeType; +import io.cryostat.net.web.http.api.ApiVersion; +import io.cryostat.recordings.RecordingNotFoundException; + +import com.nimbusds.jwt.JWT; +import dagger.Lazy; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpMethod; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.impl.HttpStatusException; +import org.apache.commons.lang3.exception.ExceptionUtils; + +class ReportGetHandler extends AbstractJwtConsumingHandler { + + private final ReportService reportService; + + @Inject + ReportGetHandler( + AuthManager auth, + AssetJwtHelper jwtFactory, + Lazy webServer, + ReportService reportService, + Logger logger) { + super(auth, jwtFactory, webServer, logger); + this.reportService = reportService; + } + + @Override + public ApiVersion apiVersion() { + return ApiVersion.BETA; + } + + @Override + public HttpMethod httpMethod() { + return HttpMethod.GET; + } + + @Override + public Set resourceActions() { + return EnumSet.of( + ResourceAction.READ_RECORDING, + ResourceAction.CREATE_REPORT, + ResourceAction.READ_REPORT); + } + + @Override + public String path() { + return basePath() + "reports/:recordingName"; + } + + @Override + public boolean isAsync() { + return true; + } + + @Override + public boolean isOrdered() { + return true; + } + + @Override + public void handleWithValidJwt(RoutingContext ctx, JWT jwt) throws Exception { + String recordingName = ctx.pathParam("recordingName"); + try { + Path report = reportService.get(recordingName).get(); + ctx.response().putHeader(HttpHeaders.CONTENT_DISPOSITION, "inline"); + ctx.response().putHeader(HttpHeaders.CONTENT_TYPE, HttpMimeType.HTML.mime()); + ctx.response() + .putHeader(HttpHeaders.CONTENT_LENGTH, Long.toString(report.toFile().length())); + ctx.response().sendFile(report.toAbsolutePath().toString()); + } catch (ExecutionException | CompletionException ee) { + if (ExceptionUtils.getRootCause(ee) instanceof RecordingNotFoundException) { + throw new HttpStatusException(404, ee); + } + throw ee; + } + } +} diff --git a/src/main/java/io/cryostat/net/web/http/api/beta/TargetRecordingGetHandler.java b/src/main/java/io/cryostat/net/web/http/api/beta/TargetRecordingGetHandler.java new file mode 100644 index 0000000000..94bb872c18 --- /dev/null +++ b/src/main/java/io/cryostat/net/web/http/api/beta/TargetRecordingGetHandler.java @@ -0,0 +1,168 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.cryostat.net.web.http.api.beta; + +import java.io.IOException; +import java.io.InputStream; +import java.util.EnumSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +import javax.inject.Inject; + +import io.cryostat.core.log.Logger; +import io.cryostat.net.AuthManager; +import io.cryostat.net.ConnectionDescriptor; +import io.cryostat.net.TargetConnectionManager; +import io.cryostat.net.security.ResourceAction; +import io.cryostat.net.security.jwt.AssetJwtHelper; +import io.cryostat.net.web.WebServer; +import io.cryostat.net.web.http.HttpMimeType; +import io.cryostat.net.web.http.api.ApiVersion; +import io.cryostat.net.web.http.api.v2.ApiException; + +import com.nimbusds.jwt.JWT; +import dagger.Lazy; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpMethod; +import io.vertx.ext.web.RoutingContext; + +class TargetRecordingGetHandler extends AbstractJwtConsumingHandler { + protected static final int WRITE_BUFFER_SIZE = 64 * 1024; // 64 KB + + private final TargetConnectionManager targetConnectionManager; + + @Inject + TargetRecordingGetHandler( + AuthManager auth, + AssetJwtHelper jwtFactory, + Lazy webServer, + TargetConnectionManager targetConnectionManager, + Logger logger) { + super(auth, jwtFactory, webServer, logger); + this.targetConnectionManager = targetConnectionManager; + } + + @Override + public ApiVersion apiVersion() { + return ApiVersion.BETA; + } + + @Override + public String path() { + return basePath() + "targets/:targetId/recordings/:recordingName"; + } + + @Override + public HttpMethod httpMethod() { + return HttpMethod.GET; + } + + @Override + public Set resourceActions() { + return EnumSet.of(ResourceAction.READ_TARGET, ResourceAction.READ_RECORDING); + } + + @Override + public boolean isAsync() { + return false; + } + + @Override + public boolean isOrdered() { + return true; + } + + @Override + public void handleWithValidJwt(RoutingContext ctx, JWT jwt) throws Exception { + String recordingName = ctx.pathParam("recordingName"); + if (recordingName != null && recordingName.endsWith(".jfr")) { + recordingName = recordingName.substring(0, recordingName.length() - 4); + } + handleRecordingDownloadRequest(ctx, jwt, recordingName); + } + + void handleRecordingDownloadRequest(RoutingContext ctx, JWT jwt, String recordingName) + throws Exception { + ConnectionDescriptor connectionDescriptor = getConnectionDescriptorFromJwt(ctx, jwt); + Optional stream = + targetConnectionManager.executeConnectedTask( + connectionDescriptor, + conn -> + conn.getService().getAvailableRecordings().stream() + .filter(r -> Objects.equals(recordingName, r.getName())) + .map( + desc -> { + try { + return conn.getService() + .openStream(desc, false); + } catch (Exception e) { + logger.error(e); + throw new ApiException(500, e); + } + }) + .filter(Objects::nonNull) + .findFirst()); + if (stream.isEmpty()) { + throw new ApiException(404, String.format("%s not found", recordingName)); + } + + ctx.response().setChunked(true); + ctx.response() + .putHeader( + HttpHeaders.CONTENT_DISPOSITION, + String.format("attachment; filename=\"%s.jfr\"", recordingName)); + ctx.response().putHeader(HttpHeaders.CONTENT_TYPE, HttpMimeType.OCTET_STREAM.mime()); + try (InputStream s = stream.get()) { + byte[] buff = new byte[WRITE_BUFFER_SIZE]; + int n; + while ((n = s.read(buff)) != -1) { + // FIXME replace this with Vertx async IO, ie. ReadStream/WriteStream/Pump + ctx.response().write(Buffer.buffer(n).appendBytes(buff, 0, n)); + if (!targetConnectionManager.markConnectionInUse(connectionDescriptor)) { + throw new IOException( + "Target connection unexpectedly closed while streaming recording"); + } + } + + ctx.response().end(); + } + } +} diff --git a/src/main/java/io/cryostat/net/web/http/api/beta/TargetReportGetHandler.java b/src/main/java/io/cryostat/net/web/http/api/beta/TargetReportGetHandler.java new file mode 100644 index 0000000000..9209d8c5b2 --- /dev/null +++ b/src/main/java/io/cryostat/net/web/http/api/beta/TargetReportGetHandler.java @@ -0,0 +1,146 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.cryostat.net.web.http.api.beta; + +import java.util.EnumSet; +import java.util.Set; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; + +import javax.inject.Inject; + +import io.cryostat.core.log.Logger; +import io.cryostat.net.AuthManager; +import io.cryostat.net.reports.ReportService; +import io.cryostat.net.reports.SubprocessReportGenerator; +import io.cryostat.net.security.ResourceAction; +import io.cryostat.net.security.jwt.AssetJwtHelper; +import io.cryostat.net.web.WebServer; +import io.cryostat.net.web.http.HttpMimeType; +import io.cryostat.net.web.http.api.ApiVersion; +import io.cryostat.recordings.RecordingNotFoundException; + +import com.nimbusds.jwt.JWT; +import dagger.Lazy; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpMethod; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.impl.HttpStatusException; +import org.apache.commons.lang3.exception.ExceptionUtils; + +class TargetReportGetHandler extends AbstractJwtConsumingHandler { + + protected final ReportService reportService; + + @Inject + TargetReportGetHandler( + AuthManager auth, + AssetJwtHelper jwtFactory, + Lazy webServer, + ReportService reportService, + Logger logger) { + super(auth, jwtFactory, webServer, logger); + this.reportService = reportService; + } + + @Override + public ApiVersion apiVersion() { + return ApiVersion.BETA; + } + + @Override + public HttpMethod httpMethod() { + return HttpMethod.GET; + } + + @Override + public String path() { + return basePath() + "targets/:targetId/reports/:recordingName"; + } + + @Override + public Set resourceActions() { + return EnumSet.of( + ResourceAction.READ_TARGET, + ResourceAction.READ_RECORDING, + ResourceAction.CREATE_REPORT, + ResourceAction.READ_REPORT); + } + + @Override + public boolean isAsync() { + return false; + } + + @Override + public boolean isOrdered() { + return true; + } + + @Override + public void handleWithValidJwt(RoutingContext ctx, JWT jwt) throws Exception { + String recordingName = ctx.pathParam("recordingName"); + ctx.response().putHeader(HttpHeaders.CONTENT_DISPOSITION, "inline"); + ctx.response().putHeader(HttpHeaders.CONTENT_TYPE, HttpMimeType.HTML.mime()); + try { + ctx.response() + .end( + reportService + .get(getConnectionDescriptorFromJwt(ctx, jwt), recordingName) + .get()); + } catch (CompletionException | ExecutionException ee) { + + Exception rootCause = (Exception) ExceptionUtils.getRootCause(ee); + + if (rootCause instanceof RecordingNotFoundException + || targetRecordingNotFound(rootCause)) { + throw new HttpStatusException(404, ee); + } + throw ee; + } + } + + private boolean targetRecordingNotFound(Exception rootCause) { + return rootCause instanceof SubprocessReportGenerator.ReportGenerationException + && (((SubprocessReportGenerator.ReportGenerationException) rootCause) + .getStatus() + == SubprocessReportGenerator.ExitStatus.TARGET_CONNECTION_FAILURE) + || (((SubprocessReportGenerator.ReportGenerationException) rootCause).getStatus() + == SubprocessReportGenerator.ExitStatus.NO_SUCH_RECORDING); + } +} diff --git a/src/main/java/io/cryostat/net/web/http/api/beta/TargetTemplateGetHandler.java b/src/main/java/io/cryostat/net/web/http/api/beta/TargetTemplateGetHandler.java new file mode 100644 index 0000000000..7f2e9cec5a --- /dev/null +++ b/src/main/java/io/cryostat/net/web/http/api/beta/TargetTemplateGetHandler.java @@ -0,0 +1,120 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.cryostat.net.web.http.api.beta; + +import java.util.EnumSet; +import java.util.Set; + +import javax.inject.Inject; + +import io.cryostat.core.log.Logger; +import io.cryostat.core.templates.TemplateType; +import io.cryostat.net.AuthManager; +import io.cryostat.net.TargetConnectionManager; +import io.cryostat.net.security.ResourceAction; +import io.cryostat.net.security.jwt.AssetJwtHelper; +import io.cryostat.net.web.WebServer; +import io.cryostat.net.web.http.HttpMimeType; +import io.cryostat.net.web.http.api.ApiVersion; + +import com.nimbusds.jwt.JWT; +import dagger.Lazy; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpMethod; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.impl.HttpStatusException; + +class TargetTemplateGetHandler extends AbstractJwtConsumingHandler { + + private final TargetConnectionManager targetConnectionManager; + + @Inject + TargetTemplateGetHandler( + AuthManager auth, + AssetJwtHelper jwt, + Lazy webServer, + TargetConnectionManager targetConnectionManager, + Logger logger) { + super(auth, jwt, webServer, logger); + this.targetConnectionManager = targetConnectionManager; + } + + @Override + public ApiVersion apiVersion() { + return ApiVersion.BETA; + } + + @Override + public HttpMethod httpMethod() { + return HttpMethod.GET; + } + + @Override + public String path() { + return basePath() + "targets/:targetId/templates/:templateName/type/:templateType"; + } + + @Override + public Set resourceActions() { + return EnumSet.of(ResourceAction.READ_TARGET, ResourceAction.READ_TEMPLATE); + } + + @Override + public boolean isAsync() { + return false; + } + + @Override + public void handleWithValidJwt(RoutingContext ctx, JWT jwt) throws Exception { + String templateName = ctx.pathParam("templateName"); + TemplateType templateType = TemplateType.valueOf(ctx.pathParam("templateType")); + targetConnectionManager + .executeConnectedTask( + getConnectionDescriptorFromJwt(ctx, jwt), + conn -> conn.getTemplateService().getXml(templateName, templateType)) + .ifPresentOrElse( + doc -> { + ctx.response() + .putHeader(HttpHeaders.CONTENT_TYPE, HttpMimeType.JFC.mime()); + ctx.response().end(doc.toString()); + }, + () -> { + throw new HttpStatusException(404); + }); + } +} diff --git a/src/main/java/io/cryostat/net/web/http/api/v2/ApiException.java b/src/main/java/io/cryostat/net/web/http/api/v2/ApiException.java index bc35b393e2..b655cf6879 100644 --- a/src/main/java/io/cryostat/net/web/http/api/v2/ApiException.java +++ b/src/main/java/io/cryostat/net/web/http/api/v2/ApiException.java @@ -44,29 +44,29 @@ public class ApiException extends HttpStatusException { protected final String apiStatus; protected final String reason; - ApiException(int statusCode, String apiStatus, String reason, Throwable cause) { + public ApiException(int statusCode, String apiStatus, String reason, Throwable cause) { super(statusCode, cause); this.apiStatus = apiStatus; this.reason = reason; } - ApiException(int statusCode, String apiStatus, String reason) { + public ApiException(int statusCode, String apiStatus, String reason) { this(statusCode, apiStatus, reason, null); } - ApiException(int statusCode, String reason, Throwable cause) { + public ApiException(int statusCode, String reason, Throwable cause) { this(statusCode, null, reason, cause); } - ApiException(int statusCode, String reason) { + public ApiException(int statusCode, String reason) { this(statusCode, null, reason, null); } - ApiException(int statusCode, Throwable cause) { + public ApiException(int statusCode, Throwable cause) { this(statusCode, null, cause.getMessage(), cause); } - ApiException(int statusCode) { + public ApiException(int statusCode) { this(statusCode, (String) null); } diff --git a/src/test/java/io/cryostat/net/web/http/api/beta/AbstractJwtConsumingHandlerTest.java b/src/test/java/io/cryostat/net/web/http/api/beta/AbstractJwtConsumingHandlerTest.java new file mode 100644 index 0000000000..986ad4f89e --- /dev/null +++ b/src/test/java/io/cryostat/net/web/http/api/beta/AbstractJwtConsumingHandlerTest.java @@ -0,0 +1,589 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.cryostat.net.web.http.api.beta; + +import java.net.URL; +import java.net.UnknownHostException; +import java.rmi.ConnectIOException; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +import org.openjdk.jmc.rjmx.ConnectionException; + +import io.cryostat.core.log.Logger; +import io.cryostat.net.AuthManager; +import io.cryostat.net.ConnectionDescriptor; +import io.cryostat.net.OpenShiftAuthManager.PermissionDeniedException; +import io.cryostat.net.security.ResourceAction; +import io.cryostat.net.security.jwt.AssetJwtHelper; +import io.cryostat.net.web.WebServer; +import io.cryostat.net.web.http.api.ApiVersion; +import io.cryostat.net.web.http.api.v2.ApiException; + +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.proc.BadJWTException; +import dagger.Lazy; +import io.fabric8.kubernetes.client.KubernetesClientException; +import io.vertx.core.MultiMap; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.ext.web.RoutingContext; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AbstractJwtConsumingHandlerTest { + + AbstractJwtConsumingHandler handler; + @Mock AuthManager auth; + @Mock AssetJwtHelper jwtHelper; + @Mock WebServer webServer; + @Mock Logger logger; + + @Mock RoutingContext ctx; + @Mock HttpServerResponse resp; + + @BeforeEach + void setup() { + this.handler = new JwtConsumingHandler(auth, jwtHelper, () -> webServer, logger); + Mockito.lenient().when(ctx.response()).thenReturn(resp); + Mockito.lenient() + .when( + resp.putHeader( + Mockito.any(CharSequence.class), Mockito.any(CharSequence.class))) + .thenReturn(resp); + Mockito.lenient() + .when(resp.putHeader(Mockito.anyString(), Mockito.anyString())) + .thenReturn(resp); + } + + @Nested + class InvalidJwt { + + MultiMap params; + + @BeforeEach + void setup() { + params = MultiMap.caseInsensitiveMultiMap(); + Mockito.when(ctx.queryParams()).thenReturn(params); + } + + @Test + void shouldThrowIfJwtDoesntParse() throws Exception { + params.set("token", "mytoken"); + Mockito.when(jwtHelper.parseAssetDownloadJwt(Mockito.anyString())) + .thenThrow(new BadJWTException("")); + ApiException ex = + Assertions.assertThrows(ApiException.class, () -> handler.handle(ctx)); + MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(401)); + } + + @Test + void shouldThrowIfResourceClaimUriInvalid() throws Exception { + HttpServerRequest req = Mockito.mock(HttpServerRequest.class); + Mockito.when(ctx.request()).thenReturn(req); + Mockito.when(req.absoluteURI()) + .thenReturn("http://cryostat.example.com:8080/api/resource"); + + params.set("token", "mytoken"); + JWT jwt = Mockito.mock(JWT.class); + JWTClaimsSet claims = Mockito.mock(JWTClaimsSet.class); + Mockito.when(jwt.getJWTClaimsSet()).thenReturn(claims); + Mockito.when(claims.getStringClaim("resource")).thenReturn("not-a-uri"); + Mockito.when(jwtHelper.parseAssetDownloadJwt(Mockito.anyString())).thenReturn(jwt); + + URL hostUrl = new URL("http://cryostat.example.com:8080"); + Mockito.when(webServer.getHostUrl()).thenReturn(hostUrl); + + ApiException ex = + Assertions.assertThrows(ApiException.class, () -> handler.handle(ctx)); + MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(401)); + } + + @Test + void shouldThrowIfResourceClaimUriDoesntMatch() throws Exception { + HttpServerRequest req = Mockito.mock(HttpServerRequest.class); + Mockito.when(ctx.request()).thenReturn(req); + Mockito.when(req.absoluteURI()) + .thenReturn("http://cryostat.example.com:8080/api/resource"); + + params.set("token", "mytoken"); + JWT jwt = Mockito.mock(JWT.class); + JWTClaimsSet claims = Mockito.mock(JWTClaimsSet.class); + Mockito.when(jwt.getJWTClaimsSet()).thenReturn(claims); + Mockito.when(claims.getStringClaim("resource")) + .thenReturn("http://othercryostat.com:8080/api/resource"); + Mockito.when(jwtHelper.parseAssetDownloadJwt(Mockito.anyString())).thenReturn(jwt); + + URL hostUrl = new URL("http://cryostat.example.com:8080"); + Mockito.when(webServer.getHostUrl()).thenReturn(hostUrl); + + ApiException ex = + Assertions.assertThrows(ApiException.class, () -> handler.handle(ctx)); + MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(401)); + } + } + + @Nested + class AuthManagerRejection { + + MultiMap params; + + @BeforeEach + void setup() throws Exception { + params = MultiMap.caseInsensitiveMultiMap(); + HttpServerRequest req = Mockito.mock(HttpServerRequest.class); + Mockito.when(ctx.request()).thenReturn(req); + Mockito.when(ctx.queryParams()).thenReturn(params); + Mockito.when(req.absoluteURI()) + .thenReturn("http://cryostat.example.com:8080/api/resource"); + + params.set("token", "mytoken"); + JWT jwt = Mockito.mock(JWT.class); + JWTClaimsSet claims = Mockito.mock(JWTClaimsSet.class); + Mockito.when(jwt.getJWTClaimsSet()).thenReturn(claims); + Mockito.when(claims.getStringClaim("resource")) + .thenReturn("http://cryostat.example.com:8080/api/resource"); + Mockito.when(jwtHelper.parseAssetDownloadJwt(Mockito.anyString())).thenReturn(jwt); + + URL hostUrl = new URL("http://cryostat.example.com:8080"); + Mockito.when(webServer.getHostUrl()).thenReturn(hostUrl); + } + + @Test + void shouldThrow401IfAuthFails() { + Mockito.when(auth.validateHttpHeader(Mockito.any(), Mockito.any())) + .thenReturn(CompletableFuture.completedFuture(false)); + + ApiException ex = + Assertions.assertThrows(ApiException.class, () -> handler.handle(ctx)); + MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(401)); + } + + @Test + void shouldThrow401IfAuthFails2() { + Mockito.when(auth.validateHttpHeader(Mockito.any(), Mockito.any())) + .thenReturn( + CompletableFuture.failedFuture( + new PermissionDeniedException( + "namespace", "group", "resource", "verb", "reason"))); + + ApiException ex = + Assertions.assertThrows(ApiException.class, () -> handler.handle(ctx)); + MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(401)); + } + + @Test + void shouldThrow401IfAuthFails3() { + Mockito.when(auth.validateHttpHeader(Mockito.any(), Mockito.any())) + .thenReturn( + CompletableFuture.failedFuture(new KubernetesClientException("test"))); + + ApiException ex = + Assertions.assertThrows(ApiException.class, () -> handler.handle(ctx)); + MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(401)); + } + + @Test + void shouldThrow401IfAuthFails4() { + // Check a doubly-nested PermissionDeniedException + Mockito.when(auth.validateHttpHeader(Mockito.any(), Mockito.any())) + .thenReturn( + CompletableFuture.failedFuture( + new ExecutionException( + new PermissionDeniedException( + "namespace", + "group", + "resource", + "verb", + "reason")))); + + ApiException ex = + Assertions.assertThrows(ApiException.class, () -> handler.handle(ctx)); + MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(401)); + } + + @Test + void shouldThrow401IfAuthFails5() { + // Check doubly-nested KubernetesClientException with its own cause + Mockito.when(auth.validateHttpHeader(Mockito.any(), Mockito.any())) + .thenReturn( + CompletableFuture.failedFuture( + new ExecutionException( + new KubernetesClientException( + "test", new Exception("test2"))))); + + ApiException ex = + Assertions.assertThrows(ApiException.class, () -> handler.handle(ctx)); + MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(401)); + } + + @Test + void shouldThrow401IfAuthThrows() { + Mockito.when(auth.validateHttpHeader(Mockito.any(), Mockito.any())) + .thenReturn(CompletableFuture.failedFuture(new NullPointerException())); + + ApiException ex = + Assertions.assertThrows(ApiException.class, () -> handler.handle(ctx)); + MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(401)); + } + } + + @Nested + class WithHandlerThrownException { + + MultiMap params; + + @BeforeEach + void setup() throws Exception { + params = MultiMap.caseInsensitiveMultiMap(); + HttpServerRequest req = Mockito.mock(HttpServerRequest.class); + Mockito.when(ctx.request()).thenReturn(req); + Mockito.when(ctx.queryParams()).thenReturn(params); + Mockito.when(req.absoluteURI()) + .thenReturn("http://cryostat.example.com:8080/api/resource"); + + params.set("token", "mytoken"); + JWT jwt = Mockito.mock(JWT.class); + JWTClaimsSet claims = Mockito.mock(JWTClaimsSet.class); + Mockito.when(jwt.getJWTClaimsSet()).thenReturn(claims); + Mockito.when(claims.getStringClaim("resource")) + .thenReturn("http://cryostat.example.com:8080/api/resource"); + Mockito.when(jwtHelper.parseAssetDownloadJwt(Mockito.anyString())).thenReturn(jwt); + + URL hostUrl = new URL("http://cryostat.example.com:8080"); + Mockito.when(webServer.getHostUrl()).thenReturn(hostUrl); + + Mockito.when(auth.validateHttpHeader(Mockito.any(), Mockito.any())) + .thenReturn(CompletableFuture.completedFuture(true)); + } + + @Test + void shouldPropagateIfHandlerThrowsApiException() { + Exception expectedException = new ApiException(200); + handler = + new ThrowingJwtConsumingHandler( + auth, jwtHelper, () -> webServer, logger, expectedException); + + ApiException ex = + Assertions.assertThrows(ApiException.class, () -> handler.handle(ctx)); + MatcherAssert.assertThat(ex, Matchers.sameInstance(expectedException)); + } + + @Test + void shouldThrow427IfConnectionFailsDueToTargetAuth() { + Exception cause = new SecurityException(); + Exception expectedException = new ConnectionException(""); + expectedException.initCause(cause); + handler = + new ThrowingJwtConsumingHandler( + auth, jwtHelper, () -> webServer, logger, expectedException); + + Mockito.when(ctx.response()).thenReturn(resp); + + ApiException ex = + Assertions.assertThrows(ApiException.class, () -> handler.handle(ctx)); + MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(427)); + Mockito.verify(resp).putHeader("X-JMX-Authenticate", "Basic"); + } + + @Test + void shouldThrow502IfConnectionFailsDueToSslTrust() { + Exception cause = new ConnectIOException("SSL trust"); + Exception expectedException = new ConnectionException(""); + expectedException.initCause(cause); + handler = + new ThrowingJwtConsumingHandler( + auth, jwtHelper, () -> webServer, logger, expectedException); + + ApiException ex = + Assertions.assertThrows(ApiException.class, () -> handler.handle(ctx)); + MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(502)); + MatcherAssert.assertThat(ex.getCause(), Matchers.instanceOf(ConnectionException.class)); + MatcherAssert.assertThat( + ex.getFailureReason(), Matchers.equalTo("Target SSL Untrusted")); + } + + @Test + void shouldThrow404IfConnectionFailsDueToInvalidTarget() { + Exception cause = new UnknownHostException("localhostt"); + Exception expectedException = new ConnectionException(""); + expectedException.initCause(cause); + handler = + new ThrowingJwtConsumingHandler( + auth, jwtHelper, () -> webServer, logger, expectedException); + + ApiException ex = + Assertions.assertThrows(ApiException.class, () -> handler.handle(ctx)); + MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(404)); + MatcherAssert.assertThat(ex.getFailureReason(), Matchers.equalTo("Target Not Found")); + } + + @Test + void shouldThrow500IfHandlerThrowsUnexpectedly() { + Exception expectedException = new NullPointerException(); + handler = + new ThrowingJwtConsumingHandler( + auth, jwtHelper, () -> webServer, logger, expectedException); + + ApiException ex = + Assertions.assertThrows(ApiException.class, () -> handler.handle(ctx)); + MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(500)); + } + } + + @Nested + class WithTargetAuth { + + ConnectionDescriptorHandler handler; + @Mock HttpServerRequest req; + MultiMap params; + JWTClaimsSet claims; + + @BeforeEach + void setup() throws Exception { + params = MultiMap.caseInsensitiveMultiMap(); + HttpServerRequest req = Mockito.mock(HttpServerRequest.class); + Mockito.when(ctx.request()).thenReturn(req); + Mockito.when(ctx.queryParams()).thenReturn(params); + Mockito.when(req.absoluteURI()) + .thenReturn("http://cryostat.example.com:8080/api/resource"); + + params.set("token", "mytoken"); + JWT jwt = Mockito.mock(JWT.class); + claims = Mockito.mock(JWTClaimsSet.class); + Mockito.when(jwt.getJWTClaimsSet()).thenReturn(claims); + Mockito.when(claims.getStringClaim("resource")) + .thenReturn("http://cryostat.example.com:8080/api/resource"); + Mockito.when(jwtHelper.parseAssetDownloadJwt(Mockito.anyString())).thenReturn(jwt); + + URL hostUrl = new URL("http://cryostat.example.com:8080"); + Mockito.when(webServer.getHostUrl()).thenReturn(hostUrl); + + Mockito.when(auth.validateHttpHeader(Mockito.any(), Mockito.any())) + .thenReturn(CompletableFuture.completedFuture(true)); + + handler = new ConnectionDescriptorHandler(auth, jwtHelper, () -> webServer, logger); + Mockito.when(auth.validateHttpHeader(Mockito.any(), Mockito.any())) + .thenReturn(CompletableFuture.completedFuture(true)); + } + + @Test + void shouldUseNoCredentialsWithoutJmxAuthClaim() { + String targetId = "fooTarget"; + Mockito.when(ctx.pathParam("targetId")).thenReturn(targetId); + + handler.handle(ctx); + ConnectionDescriptor desc = handler.desc; + + MatcherAssert.assertThat(desc.getTargetId(), Matchers.equalTo(targetId)); + Assertions.assertFalse(desc.getCredentials().isPresent()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "", + "credentialsWithoutAuthType", + }) + void shouldThrow427WithMalformedJmxAuthClaim(String authHeader) throws Exception { + String targetId = "fooTarget"; + Mockito.when(ctx.pathParam("targetId")).thenReturn(targetId); + Mockito.when(claims.getStringClaim("jmxauth")).thenReturn(authHeader); + + ApiException ex = + Assertions.assertThrows(ApiException.class, () -> handler.handle(ctx)); + MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(427)); + MatcherAssert.assertThat( + ex.getFailureReason(), Matchers.equalTo("Invalid jmxauth claim format")); + } + + @ParameterizedTest + @ValueSource( + strings = { + "Type credentials", + "Bearer credentials", + }) + void shouldThrow427WithBadJmxAuthClaimType(String authHeader) throws Exception { + String targetId = "fooTarget"; + Mockito.when(ctx.pathParam("targetId")).thenReturn(targetId); + Mockito.when(claims.getStringClaim("jmxauth")).thenReturn(authHeader); + + ApiException ex = + Assertions.assertThrows(ApiException.class, () -> handler.handle(ctx)); + MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(427)); + MatcherAssert.assertThat( + ex.getFailureReason(), + Matchers.equalTo("Unacceptable jmxauth credentials type")); + } + + @ParameterizedTest + @ValueSource( + strings = { + "Basic bm9zZXBhcmF0b3I=", // credential value of "noseparator" + "Basic b25lOnR3bzp0aHJlZQ==", // credential value of "one:two:three" + }) + void shouldThrow427WithBadJmxAuthClaimCredentialFormat(String authHeader) throws Exception { + String targetId = "fooTarget"; + Mockito.when(ctx.pathParam("targetId")).thenReturn(targetId); + Mockito.when(claims.getStringClaim("jmxauth")).thenReturn(authHeader); + + ApiException ex = + Assertions.assertThrows(ApiException.class, () -> handler.handle(ctx)); + MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(427)); + MatcherAssert.assertThat( + ex.getFailureReason(), + Matchers.equalTo("Unrecognized jmxauth claim credential format")); + } + + @ParameterizedTest + @ValueSource( + strings = { + "Basic foo:bar", + }) + void shouldThrow427WithUnencodedJmxAuthClaimCredentials(String authHeader) + throws Exception { + String targetId = "fooTarget"; + Mockito.when(ctx.pathParam("targetId")).thenReturn(targetId); + Mockito.when(claims.getStringClaim("jmxauth")).thenReturn(authHeader); + + ApiException ex = + Assertions.assertThrows(ApiException.class, () -> handler.handle(ctx)); + MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(427)); + MatcherAssert.assertThat( + ex.getFailureReason(), + Matchers.equalTo( + "jmxauth claim credentials do not appear to be Base64-encoded")); + } + + @Test + void shouldIncludeCredentialsFromAppropriateJmxAuthClaim() throws Exception { + String targetId = "fooTarget"; + Mockito.when(ctx.pathParam("targetId")).thenReturn(targetId); + Mockito.when(claims.getStringClaim("jmxauth")).thenReturn("Basic Zm9vOmJhcg=="); + + Assertions.assertDoesNotThrow(() -> handler.handle(ctx)); + ConnectionDescriptor desc = handler.desc; + + MatcherAssert.assertThat(desc.getTargetId(), Matchers.equalTo(targetId)); + Assertions.assertTrue(desc.getCredentials().isPresent()); + } + } + + static class JwtConsumingHandler extends AbstractJwtConsumingHandler { + JwtConsumingHandler( + AuthManager auth, + AssetJwtHelper jwtHelper, + Lazy webServer, + Logger logger) { + super(auth, jwtHelper, webServer, logger); + } + + @Override + public ApiVersion apiVersion() { + return ApiVersion.V1; + } + + @Override + public String path() { + return null; + } + + @Override + public HttpMethod httpMethod() { + return null; + } + + @Override + public Set resourceActions() { + return ResourceAction.NONE; + } + + @Override + public void handleWithValidJwt(RoutingContext ctx, JWT jwt) throws Exception {} + } + + static class ThrowingJwtConsumingHandler extends JwtConsumingHandler { + private final Exception thrown; + + ThrowingJwtConsumingHandler( + AuthManager auth, + AssetJwtHelper jwtHelper, + Lazy webServer, + Logger logger, + Exception thrown) { + super(auth, jwtHelper, webServer, logger); + this.thrown = thrown; + } + + @Override + public void handleWithValidJwt(RoutingContext ctx, JWT jwt) throws Exception { + throw thrown; + } + } + + static class ConnectionDescriptorHandler extends JwtConsumingHandler { + ConnectionDescriptor desc; + + ConnectionDescriptorHandler( + AuthManager auth, + AssetJwtHelper jwtHelper, + Lazy webServer, + Logger logger) { + super(auth, jwtHelper, webServer, logger); + } + + @Override + public void handleWithValidJwt(RoutingContext ctx, JWT jwt) throws Exception { + desc = getConnectionDescriptorFromJwt(ctx, jwt); + } + } +} diff --git a/src/test/java/io/cryostat/net/web/http/api/beta/AuthTokenPostHandlerTest.java b/src/test/java/io/cryostat/net/web/http/api/beta/AuthTokenPostHandlerTest.java new file mode 100644 index 0000000000..5493cf8f07 --- /dev/null +++ b/src/test/java/io/cryostat/net/web/http/api/beta/AuthTokenPostHandlerTest.java @@ -0,0 +1,183 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.cryostat.net.web.http.api.beta; + +import java.net.URL; +import java.util.Map; +import java.util.Set; + +import io.cryostat.MainModule; +import io.cryostat.core.log.Logger; +import io.cryostat.net.AuthManager; +import io.cryostat.net.security.jwt.AssetJwtHelper; +import io.cryostat.net.web.WebServer; +import io.cryostat.net.web.http.HttpMimeType; +import io.cryostat.net.web.http.api.ApiVersion; +import io.cryostat.net.web.http.api.v2.ApiException; +import io.cryostat.net.web.http.api.v2.IntermediateResponse; +import io.cryostat.net.web.http.api.v2.RequestParameters; + +import com.google.gson.Gson; +import io.vertx.core.MultiMap; +import io.vertx.core.http.HttpMethod; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AuthTokenPostHandlerTest { + + AuthTokenPostHandler handler; + @Mock AuthManager auth; + @Mock AssetJwtHelper jwt; + @Mock WebServer webServer; + @Mock Logger logger; + Gson gson = MainModule.provideGson(logger); + + @BeforeEach + void setup() { + this.handler = new AuthTokenPostHandler(auth, gson, jwt, () -> webServer, logger); + } + + @Nested + class ApiSpec { + @Test + void shouldBeBetaApi() { + MatcherAssert.assertThat(handler.apiVersion(), Matchers.equalTo(ApiVersion.BETA)); + } + + @Test + void shouldUseExpectedPath() { + MatcherAssert.assertThat(handler.path(), Matchers.equalTo("/api/beta/auth/token")); + } + + @Test + void shouldUsePostVerb() { + MatcherAssert.assertThat(handler.httpMethod(), Matchers.equalTo(HttpMethod.POST)); + } + + @Test + void shouldRequireNoResourceActions() { + MatcherAssert.assertThat(handler.resourceActions(), Matchers.equalTo(Set.of())); + } + + @Test + void shouldRequireAuthentication() { + Assertions.assertTrue(handler.requiresAuthentication()); + } + + @Test + void shouldHaveJsonMimeType() { + MatcherAssert.assertThat(handler.mimeType(), Matchers.equalTo(HttpMimeType.JSON)); + } + } + + @Nested + class Behaviour { + @Mock RequestParameters params; + + @Test + void shouldThrowIfNoResourceClaimMade() throws Exception { + MultiMap attrs = MultiMap.caseInsensitiveMultiMap(); + Mockito.when(params.getFormAttributes()).thenReturn(attrs); + ApiException ex = + Assertions.assertThrows(ApiException.class, () -> handler.handle(params)); + MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(400)); + } + + @ParameterizedTest + @ValueSource( + strings = { + "not a url", + "https://cryostat.example.com:8080", + "https://cryostat.com:8080/api/v1/recordings/foo.jfr", + "http://cryostat.com/api/v1/recordings/foo.jfr", + }) + void shouldThrowIfResourceClaimUrlIsInvalid(String resourceUrl) throws Exception { + URL hostUrl = new URL("http://cryostat.example.com:8080"); + Mockito.when(webServer.getHostUrl()).thenReturn(hostUrl); + MultiMap attrs = MultiMap.caseInsensitiveMultiMap(); + attrs.set("resource", resourceUrl); + Mockito.when(params.getFormAttributes()).thenReturn(attrs); + ApiException ex = + Assertions.assertThrows(ApiException.class, () -> handler.handle(params)); + MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(400)); + } + + @Test + void shouldRespondWithToken() throws Exception { + URL hostUrl = new URL("http://cryostat.example.com:8080"); + Mockito.when(webServer.getHostUrl()).thenReturn(hostUrl); + MultiMap attrs = MultiMap.caseInsensitiveMultiMap(); + String resource = "/api/v1/recordings/foo.jfr"; + attrs.set("resource", resource); + Mockito.when(params.getFormAttributes()).thenReturn(attrs); + MultiMap headers = MultiMap.caseInsensitiveMultiMap(); + headers.set("Authorization", "Basic user:pass"); + headers.set("X-JMX-Authorization", "Basic user2:pass2"); + Mockito.when(params.getHeaders()).thenReturn(headers); + + String token = "mytoken"; + Mockito.when( + jwt.createAssetDownloadJwt( + Mockito.anyString(), Mockito.anyString(), Mockito.anyString())) + .thenReturn(token); + + IntermediateResponse> resp = handler.handle(params); + MatcherAssert.assertThat(resp.getStatusCode(), Matchers.equalTo(200)); + MatcherAssert.assertThat( + resp.getBody(), + Matchers.equalTo( + Map.of("resourceUrl", String.format("%s?token=%s", resource, token)))); + + Mockito.verify(jwt) + .createAssetDownloadJwt( + "Basic user:pass", "/api/v1/recordings/foo.jfr", "Basic user2:pass2"); + Mockito.verifyNoMoreInteractions(jwt); + } + } +} diff --git a/src/test/java/io/cryostat/net/web/http/api/beta/RecordingGetHandlerTest.java b/src/test/java/io/cryostat/net/web/http/api/beta/RecordingGetHandlerTest.java new file mode 100644 index 0000000000..abc786bfb1 --- /dev/null +++ b/src/test/java/io/cryostat/net/web/http/api/beta/RecordingGetHandlerTest.java @@ -0,0 +1,167 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.cryostat.net.web.http.api.beta; + +import java.io.File; +import java.nio.file.Path; +import java.util.EnumSet; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; + +import io.cryostat.core.log.Logger; +import io.cryostat.net.AuthManager; +import io.cryostat.net.security.ResourceAction; +import io.cryostat.net.security.jwt.AssetJwtHelper; +import io.cryostat.net.web.WebServer; +import io.cryostat.net.web.http.api.ApiVersion; +import io.cryostat.recordings.RecordingArchiveHelper; +import io.cryostat.recordings.RecordingNotFoundException; + +import com.nimbusds.jwt.JWT; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.impl.HttpStatusException; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class RecordingGetHandlerTest { + + RecordingGetHandler handler; + @Mock AuthManager auth; + @Mock AssetJwtHelper jwt; + @Mock WebServer webServer; + @Mock RecordingArchiveHelper archive; + @Mock Logger logger; + + @BeforeEach + void setup() { + this.handler = new RecordingGetHandler(auth, jwt, () -> webServer, archive, logger); + } + + @Nested + class ApiSpec { + + @Test + void shouldUseApiVersionBeta() { + MatcherAssert.assertThat(handler.apiVersion(), Matchers.equalTo(ApiVersion.BETA)); + } + + @Test + void shouldUseHttpGetVerb() { + MatcherAssert.assertThat(handler.httpMethod(), Matchers.equalTo(HttpMethod.GET)); + } + + @Test + void shouldUseExpectedPath() { + MatcherAssert.assertThat( + handler.path(), Matchers.equalTo("/api/beta/recordings/:recordingName")); + } + + @Test + void shouldRequireResourceActions() { + MatcherAssert.assertThat( + handler.resourceActions(), + Matchers.equalTo(EnumSet.of(ResourceAction.READ_RECORDING))); + } + + @Test + void shouldBeAsync() { + Assertions.assertTrue(handler.isAsync()); + } + + @Test + void shouldBeOrdered() { + Assertions.assertTrue(handler.isOrdered()); + } + } + + @Nested + class Behaviour { + + @Mock RoutingContext ctx; + @Mock JWT token; + + @Test + void shouldRespond404IfNotFound() throws Exception { + Mockito.when(ctx.pathParam("recordingName")).thenReturn("myrecording"); + Future future = + CompletableFuture.failedFuture( + new RecordingNotFoundException("archive", "myrecording")); + Mockito.when(archive.getRecordingPath(Mockito.anyString())).thenReturn(future); + HttpStatusException ex = + Assertions.assertThrows( + HttpStatusException.class, + () -> handler.handleWithValidJwt(ctx, token)); + MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(404)); + } + + @Test + void shouldSendFileIfFound() throws Exception { + HttpServerResponse resp = Mockito.mock(HttpServerResponse.class); + Mockito.when(ctx.response()).thenReturn(resp); + Mockito.when(ctx.pathParam("recordingName")).thenReturn("myrecording"); + Path path = Mockito.mock(Path.class); + Mockito.when(path.toAbsolutePath()).thenReturn(path); + Mockito.when(path.toString()).thenReturn("foo.jfr"); + File file = Mockito.mock(File.class); + Mockito.when(path.toFile()).thenReturn(file); + Mockito.when(file.length()).thenReturn(1234L); + Future future = CompletableFuture.completedFuture(path); + Mockito.when(archive.getRecordingPath(Mockito.anyString())).thenReturn(future); + + handler.handleWithValidJwt(ctx, token); + + InOrder inOrder = Mockito.inOrder(resp); + inOrder.verify(resp).putHeader(HttpHeaders.CONTENT_TYPE, "application/octet-stream"); + inOrder.verify(resp).putHeader(HttpHeaders.CONTENT_LENGTH, "1234"); + inOrder.verify(resp).sendFile("foo.jfr"); + } + } +} diff --git a/src/test/java/io/cryostat/net/web/http/api/beta/ReportGetHandlerTest.java b/src/test/java/io/cryostat/net/web/http/api/beta/ReportGetHandlerTest.java new file mode 100644 index 0000000000..d40157e14f --- /dev/null +++ b/src/test/java/io/cryostat/net/web/http/api/beta/ReportGetHandlerTest.java @@ -0,0 +1,171 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.cryostat.net.web.http.api.beta; + +import java.io.File; +import java.nio.file.Path; +import java.util.EnumSet; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; + +import io.cryostat.core.log.Logger; +import io.cryostat.net.AuthManager; +import io.cryostat.net.reports.ReportService; +import io.cryostat.net.security.ResourceAction; +import io.cryostat.net.security.jwt.AssetJwtHelper; +import io.cryostat.net.web.WebServer; +import io.cryostat.net.web.http.api.ApiVersion; +import io.cryostat.recordings.RecordingNotFoundException; + +import com.nimbusds.jwt.JWT; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.impl.HttpStatusException; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ReportGetHandlerTest { + + ReportGetHandler handler; + @Mock AuthManager auth; + @Mock AssetJwtHelper jwt; + @Mock WebServer webServer; + @Mock ReportService reports; + @Mock Logger logger; + + @BeforeEach + void setup() { + this.handler = new ReportGetHandler(auth, jwt, () -> webServer, reports, logger); + } + + @Nested + class ApiSpec { + + @Test + void shouldUseApiVersionBeta() { + MatcherAssert.assertThat(handler.apiVersion(), Matchers.equalTo(ApiVersion.BETA)); + } + + @Test + void shouldUseHttpGetVerb() { + MatcherAssert.assertThat(handler.httpMethod(), Matchers.equalTo(HttpMethod.GET)); + } + + @Test + void shouldUseExpectedPath() { + MatcherAssert.assertThat( + handler.path(), Matchers.equalTo("/api/beta/reports/:recordingName")); + } + + @Test + void shouldRequireResourceActions() { + MatcherAssert.assertThat( + handler.resourceActions(), + Matchers.equalTo( + EnumSet.of( + ResourceAction.READ_RECORDING, + ResourceAction.CREATE_REPORT, + ResourceAction.READ_REPORT))); + } + + @Test + void shouldBeAsync() { + Assertions.assertTrue(handler.isAsync()); + } + + @Test + void shouldBeOrdered() { + Assertions.assertTrue(handler.isOrdered()); + } + } + + @Nested + class Behaviour { + + @Mock RoutingContext ctx; + @Mock JWT token; + + @Test + void shouldRespond404IfNotFound() throws Exception { + Mockito.when(ctx.pathParam("recordingName")).thenReturn("myrecording"); + Future future = + CompletableFuture.failedFuture( + new RecordingNotFoundException("archive", "myrecording")); + Mockito.when(reports.get(Mockito.anyString())).thenReturn(future); + HttpStatusException ex = + Assertions.assertThrows( + HttpStatusException.class, + () -> handler.handleWithValidJwt(ctx, token)); + MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(404)); + } + + @Test + void shouldSendFileIfFound() throws Exception { + HttpServerResponse resp = Mockito.mock(HttpServerResponse.class); + Mockito.when(ctx.response()).thenReturn(resp); + Mockito.when(ctx.pathParam("recordingName")).thenReturn("myrecording"); + Path path = Mockito.mock(Path.class); + Mockito.when(path.toAbsolutePath()).thenReturn(path); + Mockito.when(path.toString()).thenReturn("foo.jfr"); + File file = Mockito.mock(File.class); + Mockito.when(path.toFile()).thenReturn(file); + Mockito.when(file.length()).thenReturn(1234L); + Future future = CompletableFuture.completedFuture(path); + Mockito.when(reports.get(Mockito.anyString())).thenReturn(future); + + handler.handleWithValidJwt(ctx, token); + + InOrder inOrder = Mockito.inOrder(resp); + inOrder.verify(resp).putHeader(HttpHeaders.CONTENT_TYPE, "text/html"); + inOrder.verify(resp).putHeader(HttpHeaders.CONTENT_LENGTH, "1234"); + inOrder.verify(resp).sendFile("foo.jfr"); + } + } +} diff --git a/src/test/java/io/cryostat/net/web/http/api/beta/TargetRecordingGetHandlerTest.java b/src/test/java/io/cryostat/net/web/http/api/beta/TargetRecordingGetHandlerTest.java new file mode 100644 index 0000000000..89d2410dc8 --- /dev/null +++ b/src/test/java/io/cryostat/net/web/http/api/beta/TargetRecordingGetHandlerTest.java @@ -0,0 +1,249 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.cryostat.net.web.http.api.beta; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.EnumSet; +import java.util.List; +import java.util.Optional; + +import org.openjdk.jmc.rjmx.services.jfr.FlightRecorderException; +import org.openjdk.jmc.rjmx.services.jfr.IFlightRecorderService; +import org.openjdk.jmc.rjmx.services.jfr.IRecordingDescriptor; + +import io.cryostat.core.log.Logger; +import io.cryostat.core.net.JFRConnection; +import io.cryostat.net.AuthManager; +import io.cryostat.net.ConnectionDescriptor; +import io.cryostat.net.TargetConnectionManager; +import io.cryostat.net.security.ResourceAction; +import io.cryostat.net.security.jwt.AssetJwtHelper; +import io.cryostat.net.web.WebServer; +import io.cryostat.net.web.http.api.ApiVersion; + +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTClaimsSet; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.impl.HttpStatusException; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.jsoup.nodes.Document; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.stubbing.Answer; + +@ExtendWith(MockitoExtension.class) +class TargetRecordingGetHandlerTest { + + TargetRecordingGetHandler handler; + @Mock AuthManager auth; + @Mock AssetJwtHelper jwt; + @Mock WebServer webServer; + @Mock TargetConnectionManager targetConnectionManager; + @Mock Logger logger; + + @BeforeEach + void setup() { + this.handler = + new TargetRecordingGetHandler( + auth, jwt, () -> webServer, targetConnectionManager, logger); + } + + @Nested + class ApiSpec { + + @Test + void shouldUseApiVersionBeta() { + MatcherAssert.assertThat(handler.apiVersion(), Matchers.equalTo(ApiVersion.BETA)); + } + + @Test + void shouldUseHttpGetVerb() { + MatcherAssert.assertThat(handler.httpMethod(), Matchers.equalTo(HttpMethod.GET)); + } + + @Test + void shouldUseExpectedPath() { + MatcherAssert.assertThat( + handler.path(), + Matchers.equalTo("/api/beta/targets/:targetId/recordings/:recordingName")); + } + + @Test + void shouldRequireResourceActions() { + MatcherAssert.assertThat( + handler.resourceActions(), + Matchers.equalTo( + EnumSet.of(ResourceAction.READ_TARGET, ResourceAction.READ_RECORDING))); + } + + @Test + void shouldNotBeAsync() { + Assertions.assertFalse(handler.isAsync()); + } + + @Test + void shouldBeOrdered() { + Assertions.assertTrue(handler.isOrdered()); + } + } + + @Nested + class Behaviour { + + @Mock RoutingContext ctx; + @Mock JWT token; + @Mock JFRConnection conn; + @Mock IFlightRecorderService svc; + + @Test + void shouldRespond404IfNotFound() throws Exception { + Mockito.when(ctx.pathParam("recordingName")).thenReturn("myrecording"); + JWTClaimsSet claims = Mockito.mock(JWTClaimsSet.class); + Mockito.when(claims.getStringClaim(Mockito.anyString())).thenReturn(null); + Mockito.when(token.getJWTClaimsSet()).thenReturn(claims); + Mockito.when( + targetConnectionManager.executeConnectedTask( + Mockito.any(ConnectionDescriptor.class), Mockito.any())) + .thenAnswer( + new Answer<>() { + @Override + public Optional answer(InvocationOnMock args) + throws Throwable { + TargetConnectionManager.ConnectedTask ct = + (TargetConnectionManager.ConnectedTask) + args.getArguments()[1]; + return (Optional) ct.execute(conn); + } + }); + Mockito.when(conn.getService()).thenReturn(svc); + Mockito.when(svc.getAvailableRecordings()).thenReturn(List.of()); + HttpStatusException ex = + Assertions.assertThrows( + HttpStatusException.class, + () -> handler.handleWithValidJwt(ctx, token)); + MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(404)); + } + + @Test + void shouldRespond500IfStreamCannotBeOpened() throws Exception { + Mockito.when(ctx.pathParam("recordingName")).thenReturn("myrecording"); + JWTClaimsSet claims = Mockito.mock(JWTClaimsSet.class); + Mockito.when(claims.getStringClaim(Mockito.anyString())).thenReturn(null); + Mockito.when(token.getJWTClaimsSet()).thenReturn(claims); + Mockito.when( + targetConnectionManager.executeConnectedTask( + Mockito.any(ConnectionDescriptor.class), Mockito.any())) + .thenAnswer( + new Answer<>() { + @Override + public Optional answer(InvocationOnMock args) + throws Throwable { + TargetConnectionManager.ConnectedTask ct = + (TargetConnectionManager.ConnectedTask) + args.getArguments()[1]; + return (Optional) ct.execute(conn); + } + }); + Mockito.when(conn.getService()).thenReturn(svc); + IRecordingDescriptor desc = Mockito.mock(IRecordingDescriptor.class); + Mockito.when(desc.getName()).thenReturn("myrecording"); + Mockito.when(svc.getAvailableRecordings()).thenReturn(List.of(desc)); + Mockito.when(svc.openStream(Mockito.any(), Mockito.eq(false))) + .thenThrow(new FlightRecorderException("")); + HttpStatusException ex = + Assertions.assertThrows( + HttpStatusException.class, + () -> handler.handleWithValidJwt(ctx, token)); + MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(500)); + } + + @Test + void shouldSendFileIfFound() throws Exception { + HttpServerResponse resp = Mockito.mock(HttpServerResponse.class); + Mockito.when(ctx.response()).thenReturn(resp); + Mockito.when(ctx.pathParam("recordingName")).thenReturn("myrecording"); + JWTClaimsSet claims = Mockito.mock(JWTClaimsSet.class); + Mockito.when(claims.getStringClaim(Mockito.anyString())).thenReturn(null); + Mockito.when(token.getJWTClaimsSet()).thenReturn(claims); + Mockito.when( + targetConnectionManager.executeConnectedTask( + Mockito.any(ConnectionDescriptor.class), Mockito.any())) + .thenAnswer( + new Answer<>() { + @Override + public Optional answer(InvocationOnMock args) + throws Throwable { + TargetConnectionManager.ConnectedTask ct = + (TargetConnectionManager.ConnectedTask) + args.getArguments()[1]; + return (Optional) ct.execute(conn); + } + }); + Mockito.when(conn.getService()).thenReturn(svc); + IRecordingDescriptor desc = Mockito.mock(IRecordingDescriptor.class); + Mockito.when(desc.getName()).thenReturn("myrecording"); + Mockito.when(svc.getAvailableRecordings()).thenReturn(List.of(desc)); + InputStream stream = new ByteArrayInputStream("datastream".getBytes()); + Mockito.when(svc.openStream(Mockito.any(), Mockito.eq(false))).thenReturn(stream); + Mockito.when(targetConnectionManager.markConnectionInUse(Mockito.any())) + .thenReturn(true); + + handler.handleWithValidJwt(ctx, token); + + InOrder inOrder = Mockito.inOrder(resp); + inOrder.verify(resp).setChunked(true); + inOrder.verify(resp).putHeader(HttpHeaders.CONTENT_TYPE, "application/octet-stream"); + inOrder.verify(resp, Mockito.times(1)).write(Mockito.any(Buffer.class)); + inOrder.verify(resp).end(); + } + } +} diff --git a/src/test/java/io/cryostat/net/web/http/api/beta/TargetReportGetHandlerTest.java b/src/test/java/io/cryostat/net/web/http/api/beta/TargetReportGetHandlerTest.java new file mode 100644 index 0000000000..754ccf4ff7 --- /dev/null +++ b/src/test/java/io/cryostat/net/web/http/api/beta/TargetReportGetHandlerTest.java @@ -0,0 +1,171 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.cryostat.net.web.http.api.beta; + +import java.util.EnumSet; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; + +import io.cryostat.core.log.Logger; +import io.cryostat.net.AuthManager; +import io.cryostat.net.reports.ReportService; +import io.cryostat.net.security.ResourceAction; +import io.cryostat.net.security.jwt.AssetJwtHelper; +import io.cryostat.net.web.WebServer; +import io.cryostat.net.web.http.api.ApiVersion; +import io.cryostat.recordings.RecordingNotFoundException; + +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTClaimsSet; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.impl.HttpStatusException; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TargetReportGetHandlerTest { + + TargetReportGetHandler handler; + @Mock AuthManager auth; + @Mock AssetJwtHelper jwt; + @Mock WebServer webServer; + @Mock ReportService reports; + @Mock Logger logger; + + @BeforeEach + void setup() { + this.handler = new TargetReportGetHandler(auth, jwt, () -> webServer, reports, logger); + } + + @Nested + class ApiSpec { + + @Test + void shouldUseApiVersionBeta() { + MatcherAssert.assertThat(handler.apiVersion(), Matchers.equalTo(ApiVersion.BETA)); + } + + @Test + void shouldUseHttpGetVerb() { + MatcherAssert.assertThat(handler.httpMethod(), Matchers.equalTo(HttpMethod.GET)); + } + + @Test + void shouldUseExpectedPath() { + MatcherAssert.assertThat( + handler.path(), + Matchers.equalTo("/api/beta/targets/:targetId/reports/:recordingName")); + } + + @Test + void shouldRequireResourceActions() { + MatcherAssert.assertThat( + handler.resourceActions(), + Matchers.equalTo( + EnumSet.of( + ResourceAction.READ_TARGET, + ResourceAction.READ_RECORDING, + ResourceAction.CREATE_REPORT, + ResourceAction.READ_REPORT))); + } + + @Test + void shouldNotBeAsync() { + Assertions.assertFalse(handler.isAsync()); + } + + @Test + void shouldBeOrdered() { + Assertions.assertTrue(handler.isOrdered()); + } + } + + @Nested + class Behaviour { + + @Mock RoutingContext ctx; + @Mock JWT token; + + @Test + void shouldRespond404IfNotFound() throws Exception { + HttpServerResponse resp = Mockito.mock(HttpServerResponse.class); + Mockito.when(ctx.response()).thenReturn(resp); + Mockito.when(ctx.pathParam("recordingName")).thenReturn("myrecording"); + JWTClaimsSet claims = Mockito.mock(JWTClaimsSet.class); + Mockito.when(claims.getStringClaim(Mockito.anyString())).thenReturn(null); + Mockito.when(token.getJWTClaimsSet()).thenReturn(claims); + Future future = + CompletableFuture.failedFuture( + new RecordingNotFoundException("target", "myrecording")); + Mockito.when(reports.get(Mockito.any(), Mockito.anyString())).thenReturn(future); + HttpStatusException ex = + Assertions.assertThrows( + HttpStatusException.class, + () -> handler.handleWithValidJwt(ctx, token)); + MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(404)); + } + + @Test + void shouldSendFileIfFound() throws Exception { + HttpServerResponse resp = Mockito.mock(HttpServerResponse.class); + Mockito.when(ctx.response()).thenReturn(resp); + Mockito.when(ctx.pathParam("recordingName")).thenReturn("myrecording"); + JWTClaimsSet claims = Mockito.mock(JWTClaimsSet.class); + Mockito.when(claims.getStringClaim(Mockito.anyString())).thenReturn(null); + Mockito.when(token.getJWTClaimsSet()).thenReturn(claims); + Future future = CompletableFuture.completedFuture("report text"); + Mockito.when(reports.get(Mockito.any(), Mockito.anyString())).thenReturn(future); + + handler.handleWithValidJwt(ctx, token); + + Mockito.verify(resp).putHeader(HttpHeaders.CONTENT_TYPE, "text/html"); + Mockito.verify(resp).end("report text"); + } + } +} diff --git a/src/test/java/io/cryostat/net/web/http/api/beta/TargetTemplateGetHandlerTest.java b/src/test/java/io/cryostat/net/web/http/api/beta/TargetTemplateGetHandlerTest.java new file mode 100644 index 0000000000..dc25e4c5dc --- /dev/null +++ b/src/test/java/io/cryostat/net/web/http/api/beta/TargetTemplateGetHandlerTest.java @@ -0,0 +1,205 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.cryostat.net.web.http.api.beta; + +import java.util.EnumSet; +import java.util.Optional; + +import io.cryostat.core.log.Logger; +import io.cryostat.core.net.JFRConnection; +import io.cryostat.core.templates.TemplateService; +import io.cryostat.net.AuthManager; +import io.cryostat.net.ConnectionDescriptor; +import io.cryostat.net.TargetConnectionManager; +import io.cryostat.net.security.ResourceAction; +import io.cryostat.net.security.jwt.AssetJwtHelper; +import io.cryostat.net.web.WebServer; +import io.cryostat.net.web.http.api.ApiVersion; + +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTClaimsSet; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.impl.HttpStatusException; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.jsoup.nodes.Document; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.stubbing.Answer; + +@ExtendWith(MockitoExtension.class) +class TargetTemplateGetHandlerTest { + + TargetTemplateGetHandler handler; + @Mock AuthManager auth; + @Mock AssetJwtHelper jwt; + @Mock WebServer webServer; + @Mock TargetConnectionManager targetConnectionManager; + @Mock Logger logger; + + @BeforeEach + void setup() { + this.handler = + new TargetTemplateGetHandler( + auth, jwt, () -> webServer, targetConnectionManager, logger); + } + + @Nested + class ApiSpec { + + @Test + void shouldUseApiVersionBeta() { + MatcherAssert.assertThat(handler.apiVersion(), Matchers.equalTo(ApiVersion.BETA)); + } + + @Test + void shouldUseHttpGetVerb() { + MatcherAssert.assertThat(handler.httpMethod(), Matchers.equalTo(HttpMethod.GET)); + } + + @Test + void shouldUseExpectedPath() { + MatcherAssert.assertThat( + handler.path(), + Matchers.equalTo( + "/api/beta/targets/:targetId/templates/:templateName/type/:templateType")); + } + + @Test + void shouldRequireResourceActions() { + MatcherAssert.assertThat( + handler.resourceActions(), + Matchers.equalTo( + EnumSet.of(ResourceAction.READ_TARGET, ResourceAction.READ_TEMPLATE))); + } + + @Test + void shouldNotBeAsync() { + Assertions.assertFalse(handler.isAsync()); + } + } + + @Nested + class Behaviour { + + @Mock RoutingContext ctx; + @Mock JWT token; + @Mock JFRConnection conn; + @Mock TemplateService templateService; + + @BeforeEach + void setup() { + Mockito.when(conn.getTemplateService()).thenReturn(templateService); + } + + @Test + void shouldRespond404IfNotFound() throws Exception { + Mockito.when(ctx.pathParam("templateName")).thenReturn("mytemplate"); + Mockito.when(ctx.pathParam("templateType")).thenReturn("TARGET"); + JWTClaimsSet claims = Mockito.mock(JWTClaimsSet.class); + Mockito.when(claims.getStringClaim(Mockito.anyString())).thenReturn(null); + Mockito.when(token.getJWTClaimsSet()).thenReturn(claims); + Mockito.when( + targetConnectionManager.executeConnectedTask( + Mockito.any(ConnectionDescriptor.class), Mockito.any())) + .thenAnswer( + new Answer<>() { + @Override + public Optional answer(InvocationOnMock args) + throws Throwable { + TargetConnectionManager.ConnectedTask ct = + (TargetConnectionManager.ConnectedTask) + args.getArguments()[1]; + return (Optional) ct.execute(conn); + } + }); + Mockito.when(templateService.getXml(Mockito.anyString(), Mockito.any())) + .thenReturn(Optional.empty()); + HttpStatusException ex = + Assertions.assertThrows( + HttpStatusException.class, + () -> handler.handleWithValidJwt(ctx, token)); + MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(404)); + } + + @Test + void shouldSendFileIfFound() throws Exception { + HttpServerResponse resp = Mockito.mock(HttpServerResponse.class); + Mockito.when(ctx.response()).thenReturn(resp); + Mockito.when(ctx.pathParam("templateName")).thenReturn("mytemplate"); + Mockito.when(ctx.pathParam("templateType")).thenReturn("TARGET"); + JWTClaimsSet claims = Mockito.mock(JWTClaimsSet.class); + Mockito.when(claims.getStringClaim(Mockito.anyString())).thenReturn(null); + Mockito.when(token.getJWTClaimsSet()).thenReturn(claims); + Mockito.when( + targetConnectionManager.executeConnectedTask( + Mockito.any(ConnectionDescriptor.class), Mockito.any())) + .thenAnswer( + new Answer<>() { + @Override + public Optional answer(InvocationOnMock args) + throws Throwable { + TargetConnectionManager.ConnectedTask ct = + (TargetConnectionManager.ConnectedTask) + args.getArguments()[1]; + return (Optional) ct.execute(conn); + } + }); + Document doc = Mockito.mock(Document.class); + String docBody = "ehh what's up doc"; + Mockito.when(doc.toString()).thenReturn(docBody); + Mockito.when(templateService.getXml(Mockito.anyString(), Mockito.any())) + .thenReturn(Optional.of(doc)); + + handler.handleWithValidJwt(ctx, token); + + Mockito.verify(resp).putHeader(HttpHeaders.CONTENT_TYPE, "application/jfc+xml"); + Mockito.verify(resp).end(docBody); + } + } +} diff --git a/src/test/java/itest/ArchivedRecordingJwtDownloadIT.java b/src/test/java/itest/ArchivedRecordingJwtDownloadIT.java new file mode 100644 index 0000000000..a8fa3adc9d --- /dev/null +++ b/src/test/java/itest/ArchivedRecordingJwtDownloadIT.java @@ -0,0 +1,148 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package itest; + +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import io.vertx.core.MultiMap; +import io.vertx.core.buffer.Buffer; +import itest.bases.JwtAssetsSelfTest; +import org.apache.http.client.utils.URIBuilder; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class ArchivedRecordingJwtDownloadIT extends JwtAssetsSelfTest { + + static final String TEST_RECORDING_NAME = "ArchivedRecordingJwtDownloadIT"; + + @Test + void testDownloadRecordingUsingJwt() throws Exception { + URL resource = null; + URL archivedResource = null; + Path assetDownload = null; + try { + resource = createRecording(); + Thread.sleep(10_000L); + archivedResource = createArchivedRecording(resource); + String downloadUrl = + getTokenDownloadUrl( + new URIBuilder(archivedResource.toURI()) + .setPath( + archivedResource + .getPath() + .replace("/api/v1/", "/api/beta/")) + .build() + .toURL()); + assetDownload = + downloadFileAbs(downloadUrl, TEST_RECORDING_NAME, ".jfr") + .get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + Assertions.assertTrue(Files.isReadable(assetDownload)); + Assertions.assertTrue(Files.isRegularFile(assetDownload)); + MatcherAssert.assertThat(assetDownload.toFile().length(), Matchers.greaterThan(0L)); + } finally { + if (resource != null) { + cleanupCreatedResources(resource.getPath()); + } + if (archivedResource != null) { + cleanupCreatedResources(archivedResource.getPath()); + } + if (assetDownload != null) { + Files.deleteIfExists(assetDownload); + } + } + } + + URL createRecording() throws Exception { + CompletableFuture future = new CompletableFuture<>(); + MultiMap form = MultiMap.caseInsensitiveMultiMap(); + form.add("recordingName", TEST_RECORDING_NAME); + form.add("duration", "10"); + form.add("events", "template=ALL"); + webClient + .post(String.format("/api/v1/targets/%s/recordings", SELF_REFERENCE_TARGET_ID)) + .sendForm( + form, + ar -> { + if (assertRequestStatus(ar, future)) { + try { + future.complete( + new URL( + ar.result() + .bodyAsJsonObject() + .getString("downloadUrl"))); + } catch (MalformedURLException e) { + throw new RuntimeException(e); + } + } + }); + return future.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + + URL createArchivedRecording(URL resource) throws Exception { + CompletableFuture future = new CompletableFuture<>(); + webClient + .patch(resource.getPath()) + .sendBuffer( + Buffer.buffer("SAVE"), + ar -> { + if (assertRequestStatus(ar, future)) { + try { + future.complete( + new URIBuilder(resource.toURI()) + .setPath( + String.format( + "/api/v1/recordings/%s", + ar.result().bodyAsString())) + .build() + .toURL()); + } catch (MalformedURLException | URISyntaxException e) { + throw new RuntimeException(e); + } + } + }); + return future.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } +} diff --git a/src/test/java/itest/ArchivedReportJwtDownloadIT.java b/src/test/java/itest/ArchivedReportJwtDownloadIT.java new file mode 100644 index 0000000000..f94d3f63f2 --- /dev/null +++ b/src/test/java/itest/ArchivedReportJwtDownloadIT.java @@ -0,0 +1,137 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package itest; + +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import io.vertx.core.MultiMap; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.json.JsonObject; +import itest.bases.JwtAssetsSelfTest; +import org.apache.http.client.utils.URIBuilder; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class ArchivedReportJwtDownloadIT extends JwtAssetsSelfTest { + + static final String TEST_RECORDING_NAME = "ArchivedReportJwtDownloadIT"; + + @Test + void testDownloadRecordingUsingJwt() throws Exception { + URL resource = null; + URL archivedResource = null; + Path assetDownload = null; + try { + JsonObject creationResponse = createRecording(); + resource = new URL(creationResponse.getString("downloadUrl")); + Thread.sleep(10_000L); + archivedResource = createArchivedRecording(resource); + URL reportUrl = new URL(creationResponse.getString("reportUrl")); + String downloadUrl = + getTokenDownloadUrl( + new URL(reportUrl.toString().replace("/api/v1/", "/api/beta/"))); + assetDownload = + downloadFileAbs(downloadUrl, TEST_RECORDING_NAME, ".html") + .get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + Assertions.assertTrue(Files.isReadable(assetDownload)); + Assertions.assertTrue(Files.isRegularFile(assetDownload)); + MatcherAssert.assertThat(assetDownload.toFile().length(), Matchers.greaterThan(0L)); + } finally { + if (resource != null) { + cleanupCreatedResources(resource.getPath()); + } + if (archivedResource != null) { + cleanupCreatedResources(archivedResource.getPath()); + } + if (assetDownload != null) { + Files.deleteIfExists(assetDownload); + } + } + } + + JsonObject createRecording() throws Exception { + CompletableFuture future = new CompletableFuture<>(); + MultiMap form = MultiMap.caseInsensitiveMultiMap(); + form.add("recordingName", TEST_RECORDING_NAME); + form.add("duration", "10"); + form.add("events", "template=ALL"); + webClient + .post(String.format("/api/v1/targets/%s/recordings", SELF_REFERENCE_TARGET_ID)) + .sendForm( + form, + ar -> { + if (assertRequestStatus(ar, future)) { + future.complete(ar.result().bodyAsJsonObject()); + } + }); + return future.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + + URL createArchivedRecording(URL resource) throws Exception { + CompletableFuture future = new CompletableFuture<>(); + webClient + .patch(resource.getPath()) + .sendBuffer( + Buffer.buffer("SAVE"), + ar -> { + if (assertRequestStatus(ar, future)) { + try { + future.complete( + new URIBuilder(resource.toURI()) + .setPath( + String.format( + "/api/v1/recordings/%s", + ar.result().bodyAsString())) + .build() + .toURL()); + } catch (MalformedURLException | URISyntaxException e) { + throw new RuntimeException(e); + } + } + }); + return future.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } +} diff --git a/src/test/java/itest/RecordingJwtDownloadIT.java b/src/test/java/itest/RecordingJwtDownloadIT.java new file mode 100644 index 0000000000..5a6576c759 --- /dev/null +++ b/src/test/java/itest/RecordingJwtDownloadIT.java @@ -0,0 +1,110 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package itest; + +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import io.vertx.core.MultiMap; +import itest.bases.JwtAssetsSelfTest; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class RecordingJwtDownloadIT extends JwtAssetsSelfTest { + + static final String TEST_RECORDING_NAME = "RecordingJwtDownloadIT"; + + @Test + void testDownloadRecordingUsingJwt() throws Exception { + URL resource = null; + Path assetDownload = null; + try { + resource = createRecording(); + String downloadUrl = getTokenDownloadUrl(resource); + Thread.sleep(10_000L); + assetDownload = + downloadFileAbs( + downloadUrl.replace("/api/v1/", "/api/beta"), + TEST_RECORDING_NAME, + ".jfr") + .get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + Assertions.assertTrue(Files.isReadable(assetDownload)); + Assertions.assertTrue(Files.isRegularFile(assetDownload)); + MatcherAssert.assertThat(assetDownload.toFile().length(), Matchers.greaterThan(0L)); + } finally { + if (resource != null) { + cleanupCreatedResources(resource.getPath()); + } + if (assetDownload != null) { + Files.deleteIfExists(assetDownload); + } + } + } + + URL createRecording() throws Exception { + CompletableFuture future = new CompletableFuture<>(); + MultiMap form = MultiMap.caseInsensitiveMultiMap(); + form.add("recordingName", TEST_RECORDING_NAME); + form.add("duration", "10"); + form.add("events", "template=ALL"); + webClient + .post(String.format("/api/v1/targets/%s/recordings", SELF_REFERENCE_TARGET_ID)) + .sendForm( + form, + ar -> { + if (assertRequestStatus(ar, future)) { + try { + future.complete( + new URL( + ar.result() + .bodyAsJsonObject() + .getString("downloadUrl"))); + } catch (MalformedURLException e) { + throw new RuntimeException(e); + } + } + }); + return future.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } +} diff --git a/src/test/java/itest/ReportJwtDownloadIT.java b/src/test/java/itest/ReportJwtDownloadIT.java new file mode 100644 index 0000000000..f807120c02 --- /dev/null +++ b/src/test/java/itest/ReportJwtDownloadIT.java @@ -0,0 +1,99 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package itest; + +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import io.vertx.core.MultiMap; +import io.vertx.core.json.JsonObject; +import itest.bases.JwtAssetsSelfTest; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class ReportJwtDownloadIT extends JwtAssetsSelfTest { + + static final String TEST_RECORDING_NAME = "ReportJwtDownloadIT"; + + @Test + void testDownloadRecordingUsingJwt() throws Exception { + JsonObject resource = null; + Path assetDownload = null; + try { + resource = createRecording(); + String downloadUrl = getTokenDownloadUrl(new URL(resource.getString("reportUrl"))); + Thread.sleep(10_000L); + assetDownload = + downloadFileAbs(downloadUrl, TEST_RECORDING_NAME, ".html") + .get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + Assertions.assertTrue(Files.isReadable(assetDownload)); + Assertions.assertTrue(Files.isRegularFile(assetDownload)); + MatcherAssert.assertThat(assetDownload.toFile().length(), Matchers.greaterThan(0L)); + } finally { + if (resource != null) { + cleanupCreatedResources(resource.getString("downloadUrl")); + } + if (assetDownload != null) { + Files.deleteIfExists(assetDownload); + } + } + } + + JsonObject createRecording() throws Exception { + CompletableFuture future = new CompletableFuture<>(); + MultiMap form = MultiMap.caseInsensitiveMultiMap(); + form.add("recordingName", TEST_RECORDING_NAME); + form.add("duration", "10"); + form.add("events", "template=ALL"); + webClient + .post(String.format("/api/v1/targets/%s/recordings", SELF_REFERENCE_TARGET_ID)) + .sendForm( + form, + ar -> { + if (assertRequestStatus(ar, future)) { + future.complete(ar.result().bodyAsJsonObject()); + } + }); + return future.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } +} diff --git a/src/test/java/itest/TemplateJwtDownloadIT.java b/src/test/java/itest/TemplateJwtDownloadIT.java new file mode 100644 index 0000000000..2b6b8719ac --- /dev/null +++ b/src/test/java/itest/TemplateJwtDownloadIT.java @@ -0,0 +1,77 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package itest; + +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; + +import itest.bases.JwtAssetsSelfTest; +import itest.util.Utils; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class TemplateJwtDownloadIT extends JwtAssetsSelfTest { + + @Test + void testDownloadRecordingUsingJwt() throws Exception { + URL resource = null; + Path assetDownload = null; + try { + resource = + new URL( + String.format( + "http://%s:%d/api/beta/targets/%s/templates/Profiling/type/TARGET", + Utils.WEB_HOST, Utils.WEB_PORT, SELF_REFERENCE_TARGET_ID)); + String downloadUrl = getTokenDownloadUrl(resource); + assetDownload = + downloadFileAbs(downloadUrl, "Profiling", ".jfc") + .get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + Assertions.assertTrue(Files.isReadable(assetDownload)); + Assertions.assertTrue(Files.isRegularFile(assetDownload)); + MatcherAssert.assertThat(assetDownload.toFile().length(), Matchers.greaterThan(0L)); + } finally { + if (assetDownload != null) { + Files.deleteIfExists(assetDownload); + } + } + } +} diff --git a/src/test/java/itest/bases/JwtAssetsSelfTest.java b/src/test/java/itest/bases/JwtAssetsSelfTest.java new file mode 100644 index 0000000000..ece473a53a --- /dev/null +++ b/src/test/java/itest/bases/JwtAssetsSelfTest.java @@ -0,0 +1,90 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package itest.bases; + +import java.net.URL; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import io.vertx.core.MultiMap; +import itest.util.ITestCleanupFailedException; + +public class JwtAssetsSelfTest extends StandardSelfTest { + + public String getTokenDownloadUrl(URL resource) throws Exception { + CompletableFuture future = new CompletableFuture<>(); + MultiMap form = MultiMap.caseInsensitiveMultiMap(); + form.add("resource", resource.toString()); + webClient + .post("/api/beta/auth/token") + .sendForm( + form, + ar -> { + if (assertRequestStatus(ar, future)) { + future.complete( + ar.result() + .bodyAsJsonObject() + .getJsonObject("data") + .getJsonObject("result") + .getString("resourceUrl")); + } + }); + return future.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + + public void cleanupCreatedResources(String path) throws Exception { + CompletableFuture future = new CompletableFuture<>(); + webClient + .delete(path) + .send( + ar -> { + if (assertRequestStatus(ar, future)) { + future.complete(null); + } + }); + + try { + future.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + throw new ITestCleanupFailedException( + String.format("Failed to delete resource %s", path), e); + } + } +} diff --git a/web-client b/web-client index b27cf51234..18fba44417 160000 --- a/web-client +++ b/web-client @@ -1 +1 @@ -Subproject commit b27cf51234d0f15482b44aad4a8ed5f26715c91d +Subproject commit 18fba44417f8a74bf422315e74b72c5d1e7a26ef