diff --git a/src/main/java/io/cryostat/Cryostat.java b/src/main/java/io/cryostat/Cryostat.java index 90362afc3f..ab9143e25f 100644 --- a/src/main/java/io/cryostat/Cryostat.java +++ b/src/main/java/io/cryostat/Cryostat.java @@ -78,6 +78,7 @@ public static void main(String[] args) throws Exception { CompletableFuture future = new CompletableFuture<>(); client.httpServer().addShutdownListener(() -> future.complete(null)); + client.credentialsManager().migrate(); client.credentialsManager().load(); client.ruleRegistry().loadRules(); client.vertx() diff --git a/src/main/java/io/cryostat/MainModule.java b/src/main/java/io/cryostat/MainModule.java index 9783c1121e..d0fe93e564 100644 --- a/src/main/java/io/cryostat/MainModule.java +++ b/src/main/java/io/cryostat/MainModule.java @@ -44,6 +44,8 @@ import javax.inject.Named; import javax.inject.Singleton; import javax.management.remote.JMXServiceURL; +import javax.script.ScriptEngine; +import javax.script.ScriptEngineManager; import io.cryostat.configuration.ConfigurationModule; import io.cryostat.configuration.Variables; @@ -155,4 +157,10 @@ static Path provideSavedRecordingsPath(Logger logger, Environment env) { logger.info("Local save path for flight recordings set as {}", archivePath); return Paths.get(archivePath); } + + @Provides + @Singleton + public static ScriptEngine provideScriptEngine() { + return new ScriptEngineManager().getEngineByName("nashorn"); + } } diff --git a/src/main/java/io/cryostat/configuration/ConfigurationModule.java b/src/main/java/io/cryostat/configuration/ConfigurationModule.java index 53b435e50b..00dc3be92e 100644 --- a/src/main/java/io/cryostat/configuration/ConfigurationModule.java +++ b/src/main/java/io/cryostat/configuration/ConfigurationModule.java @@ -53,6 +53,7 @@ import io.cryostat.core.sys.FileSystem; import io.cryostat.messaging.notifications.NotificationFactory; import io.cryostat.platform.PlatformClient; +import io.cryostat.rules.MatchExpressionEvaluator; import com.google.gson.Gson; import dagger.Module; @@ -77,6 +78,7 @@ static Path provideConfigurationPath(Logger logger, Environment env) { @Singleton static CredentialsManager provideCredentialsManager( @Named(CONFIGURATION_PATH) Path confDir, + MatchExpressionEvaluator matchExpressionEvaluator, FileSystem fs, PlatformClient platformClient, NotificationFactory notificationFactory, @@ -95,7 +97,14 @@ static CredentialsManager provideCredentialsManager( PosixFilePermission.OWNER_EXECUTE))); } return new CredentialsManager( - credentialsDir, fs, platformClient, notificationFactory, gson, base32, logger); + credentialsDir, + matchExpressionEvaluator, + fs, + platformClient, + notificationFactory, + gson, + base32, + logger); } catch (IOException e) { throw new RuntimeException(e); } diff --git a/src/main/java/io/cryostat/configuration/CredentialsManager.java b/src/main/java/io/cryostat/configuration/CredentialsManager.java index c48fa9ff30..47be657907 100644 --- a/src/main/java/io/cryostat/configuration/CredentialsManager.java +++ b/src/main/java/io/cryostat/configuration/CredentialsManager.java @@ -37,17 +37,23 @@ */ package io.cryostat.configuration; +import java.io.BufferedReader; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.nio.file.attribute.PosixFilePermission; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; -import java.util.stream.Collectors; + +import javax.script.ScriptException; import io.cryostat.core.log.Logger; import io.cryostat.core.net.Credentials; @@ -55,13 +61,16 @@ import io.cryostat.messaging.notifications.NotificationFactory; import io.cryostat.platform.PlatformClient; import io.cryostat.platform.ServiceRef; +import io.cryostat.rules.MatchExpressionEvaluator; import com.google.gson.Gson; import org.apache.commons.codec.binary.Base32; +import org.apache.commons.lang3.StringUtils; public class CredentialsManager { private final Path credentialsDir; + private final MatchExpressionEvaluator matchExpressionEvaluator; private final FileSystem fs; private final PlatformClient platformClient; private final Gson gson; @@ -72,6 +81,7 @@ public class CredentialsManager { CredentialsManager( Path credentialsDir, + MatchExpressionEvaluator matchExpressionEvaluator, FileSystem fs, PlatformClient platformClient, NotificationFactory notificationFactory, @@ -79,6 +89,7 @@ public class CredentialsManager { Base32 base32, Logger logger) { this.credentialsDir = credentialsDir; + this.matchExpressionEvaluator = matchExpressionEvaluator; this.fs = fs; this.platformClient = platformClient; this.gson = gson; @@ -87,6 +98,37 @@ public class CredentialsManager { this.credentialsMap = new HashMap<>(); } + public void migrate() throws Exception { + for (String file : this.fs.listDirectoryChildren(credentialsDir)) { + BufferedReader reader; + try { + Path path = credentialsDir.resolve(file); + reader = fs.readFile(path); + TargetSpecificStoredCredentials targetSpecificCredential = + gson.fromJson(reader, TargetSpecificStoredCredentials.class); + + String targetId = targetSpecificCredential.getTargetId(); + if (StringUtils.isNotBlank(targetId)) { + addCredentials( + targetIdToMatchExpression(targetSpecificCredential.getTargetId()), + targetSpecificCredential.getCredentials()); + fs.deleteIfExists(path); + logger.info("Migrated {}", path); + } + } catch (IOException e) { + logger.warn(e); + continue; + } + } + } + + public static String targetIdToMatchExpression(String targetId) { + if (StringUtils.isBlank(targetId)) { + return null; + } + return String.format("target.connectUrl == \"%s\"", targetId); + } + public void load() throws IOException { this.fs.listDirectoryChildren(credentialsDir).stream() .peek(n -> logger.trace("Credentials file: {}", n)) @@ -102,16 +144,17 @@ public void load() throws IOException { }) .filter(Objects::nonNull) .map(reader -> gson.fromJson(reader, StoredCredentials.class)) - .forEach(sc -> credentialsMap.put(sc.getTargetId(), sc.getCredentials())); + .forEach(sc -> credentialsMap.put(sc.getMatchExpression(), sc.getCredentials())); } - public boolean addCredentials(String targetId, Credentials credentials) throws IOException { - boolean replaced = credentialsMap.containsKey(targetId); - credentialsMap.put(targetId, credentials); - Path destination = getPersistedPath(targetId); + public boolean addCredentials(String matchExpression, Credentials credentials) + throws IOException { + boolean replaced = credentialsMap.containsKey(matchExpression); + credentialsMap.put(matchExpression, credentials); + Path destination = getPersistedPath(matchExpression); fs.writeString( destination, - gson.toJson(new StoredCredentials(targetId, credentials)), + gson.toJson(new StoredCredentials(matchExpression, credentials)), StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); @@ -121,48 +164,193 @@ public boolean addCredentials(String targetId, Credentials credentials) throws I return replaced; } - public boolean removeCredentials(String targetId) throws IOException { - Credentials deleted = this.credentialsMap.remove(targetId); - fs.deleteIfExists(getPersistedPath(targetId)); + public boolean removeCredentials(String matchExpression) throws IOException { + Credentials deleted = this.credentialsMap.remove(matchExpression); + fs.deleteIfExists(getPersistedPath(matchExpression)); return deleted != null; } - public Credentials getCredentials(String targetId) { - return this.credentialsMap.get(targetId); + public Credentials getCredentialsByTargetId(String targetId) { + for (ServiceRef service : this.platformClient.listDiscoverableServices()) { + if (Objects.equals(targetId, service.getServiceUri().toString())) { + return getCredentials(service); + } + } + return null; } public Credentials getCredentials(ServiceRef serviceRef) { - return getCredentials(serviceRef.getServiceUri().toString()); + for (Map.Entry entry : credentialsMap.entrySet()) { + try { + if (matchExpressionEvaluator.applies(entry.getKey(), serviceRef)) { + return entry.getValue(); + } + } catch (ScriptException e) { + logger.error(e); + continue; + } + } + return null; + } + + public Collection getServiceRefsWithCredentials() { + List result = new ArrayList<>(); + for (ServiceRef service : this.platformClient.listDiscoverableServices()) { + Credentials credentials = getCredentials(service); + if (credentials != null) { + result.add(service); + } + } + return result; + } + + public Collection getMatchExpressions() { + return credentialsMap.keySet(); } - public List getCredentialKeys() { - return this.platformClient.listDiscoverableServices().stream() - .filter(target -> credentialsMap.containsKey(target.getServiceUri().toString())) - .collect(Collectors.toList()); + public List getMatchExpressionsWithMatchedTargets() { + List result = new ArrayList<>(); + List targets = platformClient.listDiscoverableServices(); + for (String expr : getMatchExpressions()) { + Set matchedTargets = new HashSet<>(); + for (ServiceRef target : targets) { + try { + if (matchExpressionEvaluator.applies(expr, target)) { + matchedTargets.add(target); + } + } catch (ScriptException e) { + logger.error(e); + continue; + } + } + MatchedCredentials match = new MatchedCredentials(expr, matchedTargets); + result.add(match); + } + return result; } - private Path getPersistedPath(String targetId) { + private Path getPersistedPath(String matchExpression) { return credentialsDir.resolve( String.format( "%s.json", - base32.encodeAsString(targetId.getBytes(StandardCharsets.UTF_8)))); + base32.encodeAsString(matchExpression.getBytes(StandardCharsets.UTF_8)))); } - public static class StoredCredentials { + public static class MatchedCredentials { + private final String matchExpression; + private final Collection targets; + + MatchedCredentials(String matchExpression, Collection targets) { + this.matchExpression = matchExpression; + this.targets = new HashSet<>(targets); + } + + public String getMatchExpression() { + return matchExpression; + } + + public Collection getTargets() { + return Collections.unmodifiableCollection(targets); + } + + @Override + public int hashCode() { + return Objects.hash(matchExpression, targets); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + MatchedCredentials other = (MatchedCredentials) obj; + return Objects.equals(matchExpression, other.matchExpression) + && Objects.equals(targets, other.targets); + } + } + + static class StoredCredentials { + private final String matchExpression; + private final Credentials credentials; + + StoredCredentials(String matchExpression, Credentials credentials) { + this.matchExpression = matchExpression; + this.credentials = credentials; + } + + String getMatchExpression() { + return this.matchExpression; + } + + Credentials getCredentials() { + return this.credentials; + } + + @Override + public int hashCode() { + return Objects.hash(credentials, matchExpression); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + StoredCredentials other = (StoredCredentials) obj; + return Objects.equals(credentials, other.credentials) + && Objects.equals(matchExpression, other.matchExpression); + } + } + + @Deprecated(since = "2.2", forRemoval = true) + static class TargetSpecificStoredCredentials { private final String targetId; private final Credentials credentials; - StoredCredentials(String targetId, Credentials credentials) { + TargetSpecificStoredCredentials(String targetId, Credentials credentials) { this.targetId = targetId; this.credentials = credentials; } - public String getTargetId() { + String getTargetId() { return this.targetId; } - public Credentials getCredentials() { + Credentials getCredentials() { return this.credentials; } + + @Override + public int hashCode() { + return Objects.hash(credentials, targetId); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + TargetSpecificStoredCredentials other = (TargetSpecificStoredCredentials) obj; + return Objects.equals(credentials, other.credentials) + && Objects.equals(targetId, other.targetId); + } } } diff --git a/src/main/java/io/cryostat/net/web/http/AbstractAuthenticatedRequestHandler.java b/src/main/java/io/cryostat/net/web/http/AbstractAuthenticatedRequestHandler.java index a3e58f3087..cfa3ed9515 100644 --- a/src/main/java/io/cryostat/net/web/http/AbstractAuthenticatedRequestHandler.java +++ b/src/main/java/io/cryostat/net/web/http/AbstractAuthenticatedRequestHandler.java @@ -37,12 +37,10 @@ */ package io.cryostat.net.web.http; -import java.io.IOException; import java.net.UnknownHostException; import java.nio.charset.StandardCharsets; import java.rmi.ConnectIOException; import java.util.Base64; -import java.util.Optional; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.regex.Matcher; @@ -123,7 +121,7 @@ protected Future validateRequestAuthorization(HttpServerRequest req) th protected ConnectionDescriptor getConnectionDescriptorFromContext(RoutingContext ctx) { String targetId = ctx.pathParam("targetId"); - Credentials credentials = credentialsManager.getCredentials(targetId); + Credentials credentials = credentialsManager.getCredentialsByTargetId(targetId); if (ctx.request().headers().contains(JMX_AUTHORIZATION_HEADER)) { String proxyAuth = ctx.request().getHeader(JMX_AUTHORIZATION_HEADER); Matcher m = AUTH_HEADER_PATTERN.matcher(proxyAuth); @@ -170,35 +168,16 @@ private boolean isAuthFailure(ExecutionException e) { private void handleConnectionException(RoutingContext ctx, ConnectionException e) { Throwable cause = e.getCause(); - try { - if (cause instanceof SecurityException || cause instanceof SaslException) { - ctx.response().putHeader(JMX_AUTHENTICATE_HEADER, "Basic"); - throw new HttpException(427, "JMX Authentication Failure", e); - } - Throwable rootCause = ExceptionUtils.getRootCause(e); - if (rootCause instanceof ConnectIOException) { - throw new HttpException(502, "Target SSL Untrusted", e); - } - if (rootCause instanceof UnknownHostException) { - throw new HttpException(404, "Target Not Found", e); - } - } finally { - this.removeCredentialsIfPresent(ctx); + if (cause instanceof SecurityException || cause instanceof SaslException) { + ctx.response().putHeader(JMX_AUTHENTICATE_HEADER, "Basic"); + throw new HttpException(427, "JMX Authentication Failure", e); + } + Throwable rootCause = ExceptionUtils.getRootCause(e); + if (rootCause instanceof ConnectIOException) { + throw new HttpException(502, "Target SSL Untrusted", e); + } + if (rootCause instanceof UnknownHostException) { + throw new HttpException(404, "Target Not Found", e); } - } - - private void removeCredentialsIfPresent(RoutingContext ctx) { - Optional targetId = Optional.ofNullable(ctx.pathParam("targetId")); - - targetId.ifPresent( - id -> { - if (credentialsManager.getCredentials(id) != null) { - try { - credentialsManager.removeCredentials(id); - } catch (IOException ioe) { - logger.error(ioe); - } - } - }); } } diff --git a/src/main/java/io/cryostat/net/web/http/api/v2/CredentialsGetHandler.java b/src/main/java/io/cryostat/net/web/http/api/v2/CredentialsGetHandler.java new file mode 100644 index 0000000000..ec5098f2ab --- /dev/null +++ b/src/main/java/io/cryostat/net/web/http/api/v2/CredentialsGetHandler.java @@ -0,0 +1,109 @@ +/* + * 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.v2; + +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +import javax.inject.Inject; + +import io.cryostat.configuration.CredentialsManager; +import io.cryostat.configuration.CredentialsManager.MatchedCredentials; +import io.cryostat.core.log.Logger; +import io.cryostat.net.AuthManager; +import io.cryostat.net.security.ResourceAction; +import io.cryostat.net.web.http.HttpMimeType; +import io.cryostat.net.web.http.api.ApiVersion; + +import com.google.gson.Gson; +import io.vertx.core.http.HttpMethod; + +class CredentialsGetHandler extends AbstractV2RequestHandler> { + + private final CredentialsManager credentialsManager; + + @Inject + CredentialsGetHandler( + AuthManager auth, CredentialsManager credentialsManager, Gson gson, Logger logger) { + super(auth, gson); + this.credentialsManager = credentialsManager; + } + + @Override + public boolean requiresAuthentication() { + return true; + } + + @Override + public ApiVersion apiVersion() { + return ApiVersion.V2_2; + } + + @Override + public HttpMethod httpMethod() { + return HttpMethod.GET; + } + + @Override + public Set resourceActions() { + return EnumSet.of(ResourceAction.READ_CREDENTIALS); + } + + @Override + public String path() { + return basePath() + "credentials"; + } + + @Override + public HttpMimeType mimeType() { + return HttpMimeType.JSON; + } + + @Override + public boolean isAsync() { + return false; + } + + @Override + public IntermediateResponse> handle(RequestParameters requestParams) + throws Exception { + return new IntermediateResponse>() + .body(credentialsManager.getMatchExpressionsWithMatchedTargets()); + } +} diff --git a/src/main/java/io/cryostat/net/web/http/api/v2/HttpApiV2Module.java b/src/main/java/io/cryostat/net/web/http/api/v2/HttpApiV2Module.java index adf7c335c8..2ac86c2b20 100644 --- a/src/main/java/io/cryostat/net/web/http/api/v2/HttpApiV2Module.java +++ b/src/main/java/io/cryostat/net/web/http/api/v2/HttpApiV2Module.java @@ -213,4 +213,8 @@ abstract RequestHandler bindTargetCredentialsDeleteHandler( @Binds @IntoSet abstract RequestHandler bindTargetCredentialsGetHandler(TargetCredentialsGetHandler handler); + + @Binds + @IntoSet + abstract RequestHandler bindCredentialsGetHandler(CredentialsGetHandler handler); } diff --git a/src/main/java/io/cryostat/net/web/http/api/v2/TargetCredentialsDeleteHandler.java b/src/main/java/io/cryostat/net/web/http/api/v2/TargetCredentialsDeleteHandler.java index f6657611c1..9b98f1a98c 100644 --- a/src/main/java/io/cryostat/net/web/http/api/v2/TargetCredentialsDeleteHandler.java +++ b/src/main/java/io/cryostat/net/web/http/api/v2/TargetCredentialsDeleteHandler.java @@ -114,7 +114,9 @@ public boolean isOrdered() { @Override public IntermediateResponse handle(RequestParameters params) throws ApiException { - String targetId = params.getPathParams().get("targetId"); + String targetId = + CredentialsManager.targetIdToMatchExpression( + params.getPathParams().get("targetId")); try { boolean status = this.credentialsManager.removeCredentials(targetId); diff --git a/src/main/java/io/cryostat/net/web/http/api/v2/TargetCredentialsGetHandler.java b/src/main/java/io/cryostat/net/web/http/api/v2/TargetCredentialsGetHandler.java index 6695e2acb9..b60d1a0031 100644 --- a/src/main/java/io/cryostat/net/web/http/api/v2/TargetCredentialsGetHandler.java +++ b/src/main/java/io/cryostat/net/web/http/api/v2/TargetCredentialsGetHandler.java @@ -37,6 +37,7 @@ */ package io.cryostat.net.web.http.api.v2; +import java.util.ArrayList; import java.util.EnumSet; import java.util.List; import java.util.Set; @@ -104,6 +105,6 @@ public boolean isAsync() { public IntermediateResponse> handle(RequestParameters requestParams) throws Exception { return new IntermediateResponse>() - .body(this.credentialsManager.getCredentialKeys()); + .body(new ArrayList<>(this.credentialsManager.getServiceRefsWithCredentials())); } } diff --git a/src/main/java/io/cryostat/net/web/http/api/v2/TargetCredentialsPostHandler.java b/src/main/java/io/cryostat/net/web/http/api/v2/TargetCredentialsPostHandler.java index fad7f38d91..9685e2d65e 100644 --- a/src/main/java/io/cryostat/net/web/http/api/v2/TargetCredentialsPostHandler.java +++ b/src/main/java/io/cryostat/net/web/http/api/v2/TargetCredentialsPostHandler.java @@ -120,7 +120,9 @@ public boolean isOrdered() { @Override public IntermediateResponse handle(RequestParameters params) throws ApiException { - String targetId = params.getPathParams().get("targetId"); + String targetId = + CredentialsManager.targetIdToMatchExpression( + params.getPathParams().get("targetId")); String username = params.getFormAttributes().get("username"); String password = params.getFormAttributes().get("password"); diff --git a/src/main/java/io/cryostat/rules/RuleMatcher.java b/src/main/java/io/cryostat/rules/MatchExpressionEvaluator.java similarity index 80% rename from src/main/java/io/cryostat/rules/RuleMatcher.java rename to src/main/java/io/cryostat/rules/MatchExpressionEvaluator.java index a7b51ae292..60735715b1 100644 --- a/src/main/java/io/cryostat/rules/RuleMatcher.java +++ b/src/main/java/io/cryostat/rules/MatchExpressionEvaluator.java @@ -42,7 +42,6 @@ import javax.script.Bindings; import javax.script.ScriptEngine; -import javax.script.ScriptEngineManager; import javax.script.ScriptException; import io.cryostat.platform.ServiceRef; @@ -53,23 +52,26 @@ import jdk.jfr.Label; import jdk.jfr.Name; -class RuleMatcher { +public class MatchExpressionEvaluator { - private final ScriptEngine scriptEngine = new ScriptEngineManager().getEngineByName("nashorn"); + private final ScriptEngine scriptEngine; - public boolean applies(Rule rule, ServiceRef serviceRef) throws ScriptException { - RuleAppliesEvent evt = new RuleAppliesEvent(rule.getName()); + MatchExpressionEvaluator(ScriptEngine scriptEngine) { + this.scriptEngine = scriptEngine; + } + + public boolean applies(String matchExpression, ServiceRef serviceRef) throws ScriptException { + MatchExpressionAppliesEvent evt = new MatchExpressionAppliesEvent(matchExpression); try { evt.begin(); - Object result = - this.scriptEngine.eval(rule.getMatchExpression(), createBindings(serviceRef)); + Object result = this.scriptEngine.eval(matchExpression, createBindings(serviceRef)); if (result instanceof Boolean) { return (Boolean) result; } else { throw new ScriptException( String.format( - "Rule %s non-boolean match expression evaluation result: %s", - rule.getName(), result)); + "Non-boolean match expression evaluation result: %s", + matchExpression, result)); } } finally { evt.end(); @@ -114,23 +116,23 @@ Bindings createBindings(ServiceRef serviceRef) { } } - @Name("io.cryostat.rules.RuleMatcher.RuleAppliesEvent") - @Label("Rule Expression Matching") + @Name("io.cryostat.rules.MatchExpressionEvaluator.MatchExpressionAppliesEvent") + @Label("Match Expression Evaluation") @Category("Cryostat") @SuppressFBWarnings( value = "URF_UNREAD_FIELD", justification = "The event fields are recorded with JFR instead of accessed directly") - public static class RuleAppliesEvent extends Event { + public static class MatchExpressionAppliesEvent extends Event { - String ruleName; + String matchExpression; - RuleAppliesEvent(String ruleName) { - this.ruleName = ruleName; + MatchExpressionAppliesEvent(String matchExpression) { + this.matchExpression = matchExpression; } } - @Name("io.cryostat.rules.RuleMatcher.BindingsCreationEvent") - @Label("Rule Binding Creation") + @Name("io.cryostat.rules.MatchExpressionEvaluator.BindingsCreationEvent") + @Label("Match Expression Binding Creation") @Category("Cryostat") @SuppressFBWarnings( value = "URF_UNREAD_FIELD", diff --git a/src/main/java/io/cryostat/rules/RuleProcessor.java b/src/main/java/io/cryostat/rules/RuleProcessor.java index 2cc3b88331..e4eb65af49 100644 --- a/src/main/java/io/cryostat/rules/RuleProcessor.java +++ b/src/main/java/io/cryostat/rules/RuleProcessor.java @@ -176,10 +176,7 @@ private void activate(Rule rule, ServiceRef serviceRef) { "Activating rule {} for target {}", rule.getName(), serviceRef.getServiceUri()); vertx.executeBlocking( - promise -> - promise.complete( - credentialsManager.getCredentials( - serviceRef.getServiceUri().toString()))) + promise -> promise.complete(credentialsManager.getCredentials(serviceRef))) .onSuccess(c -> logger.trace("Rule activation successful")) .onSuccess( credentials -> { diff --git a/src/main/java/io/cryostat/rules/RuleRegistry.java b/src/main/java/io/cryostat/rules/RuleRegistry.java index a188bb7282..3dba8d2871 100644 --- a/src/main/java/io/cryostat/rules/RuleRegistry.java +++ b/src/main/java/io/cryostat/rules/RuleRegistry.java @@ -60,15 +60,20 @@ public class RuleRegistry extends AbstractEventEmitter { private final Path rulesDir; - private final RuleMatcher ruleMatcher; + private final MatchExpressionEvaluator matchExpressionEvaluator; private final FileSystem fs; private final Set rules; private final Gson gson; private final Logger logger; - RuleRegistry(Path rulesDir, RuleMatcher ruleMatcher, FileSystem fs, Gson gson, Logger logger) { + RuleRegistry( + Path rulesDir, + MatchExpressionEvaluator matchExpressionEvaluator, + FileSystem fs, + Gson gson, + Logger logger) { this.rulesDir = rulesDir; - this.ruleMatcher = ruleMatcher; + this.matchExpressionEvaluator = matchExpressionEvaluator; this.fs = fs; this.gson = gson; this.logger = logger; @@ -124,7 +129,7 @@ public Optional getRule(String name) { public boolean applies(Rule rule, ServiceRef serviceRef) { try { - return ruleMatcher.applies(rule, serviceRef); + return matchExpressionEvaluator.applies(rule.getMatchExpression(), serviceRef); } catch (ScriptException se) { logger.error(se); try { diff --git a/src/main/java/io/cryostat/rules/RulesModule.java b/src/main/java/io/cryostat/rules/RulesModule.java index 9183acdb51..1532f711b9 100644 --- a/src/main/java/io/cryostat/rules/RulesModule.java +++ b/src/main/java/io/cryostat/rules/RulesModule.java @@ -45,6 +45,7 @@ import javax.inject.Named; import javax.inject.Singleton; +import javax.script.ScriptEngine; import io.cryostat.configuration.ConfigurationModule; import io.cryostat.configuration.CredentialsManager; @@ -81,7 +82,7 @@ public abstract class RulesModule { @Singleton static RuleRegistry provideRuleRegistry( @Named(ConfigurationModule.CONFIGURATION_PATH) Path confDir, - RuleMatcher ruleMatcher, + MatchExpressionEvaluator matchExpressionEvaluator, FileSystem fs, Gson gson, Logger logger) { @@ -90,7 +91,7 @@ static RuleRegistry provideRuleRegistry( if (!fs.isDirectory(rulesDir)) { Files.createDirectory(rulesDir); } - return new RuleRegistry(rulesDir, ruleMatcher, fs, gson, logger); + return new RuleRegistry(rulesDir, matchExpressionEvaluator, fs, gson, logger); } catch (IOException e) { throw new RuntimeException(e); } @@ -98,8 +99,8 @@ static RuleRegistry provideRuleRegistry( @Provides @Singleton - static RuleMatcher provideRuleMatcher() { - return new RuleMatcher(); + static MatchExpressionEvaluator provideMatchExpressionEvaluator(ScriptEngine scriptEngine) { + return new MatchExpressionEvaluator(scriptEngine); } @Provides diff --git a/src/test/java/io/cryostat/configuration/CredentialsManagerTest.java b/src/test/java/io/cryostat/configuration/CredentialsManagerTest.java new file mode 100644 index 0000000000..70433b8304 --- /dev/null +++ b/src/test/java/io/cryostat/configuration/CredentialsManagerTest.java @@ -0,0 +1,540 @@ +/* + * 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.configuration; + +import java.io.BufferedReader; +import java.io.StringReader; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.PosixFilePermission; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import io.cryostat.MainModule; +import io.cryostat.configuration.CredentialsManager.MatchedCredentials; +import io.cryostat.core.log.Logger; +import io.cryostat.core.net.Credentials; +import io.cryostat.core.sys.FileSystem; +import io.cryostat.messaging.notifications.NotificationFactory; +import io.cryostat.platform.PlatformClient; +import io.cryostat.platform.ServiceRef; +import io.cryostat.rules.MatchExpressionEvaluator; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import org.apache.commons.codec.binary.Base32; +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.ArgumentCaptor; +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 CredentialsManagerTest { + + CredentialsManager credentialsManager; + @Mock Path credentialsDir; + @Mock MatchExpressionEvaluator matchExpressionEvaluator; + @Mock FileSystem fs; + @Mock PlatformClient platformClient; + @Mock NotificationFactory notificationFactory; + @Mock Logger logger; + Base32 base32 = new Base32(); + Gson gson = MainModule.provideGson(logger); + + @BeforeEach + void setup() { + this.credentialsManager = + new CredentialsManager( + credentialsDir, + matchExpressionEvaluator, + fs, + platformClient, + notificationFactory, + gson, + base32, + logger); + } + + @Test + void initializesEmpty() throws Exception { + Mockito.when(platformClient.listDiscoverableServices()).thenReturn(List.of()); + + MatcherAssert.assertThat(credentialsManager.getMatchExpressions(), Matchers.empty()); + MatcherAssert.assertThat( + credentialsManager.getServiceRefsWithCredentials(), Matchers.empty()); + MatcherAssert.assertThat( + credentialsManager.getMatchExpressionsWithMatchedTargets(), Matchers.empty()); + Assertions.assertFalse(credentialsManager.removeCredentials("foo")); + MatcherAssert.assertThat( + credentialsManager.getCredentials(new ServiceRef(new URI("foo"), "foo")), + Matchers.nullValue()); + MatcherAssert.assertThat( + credentialsManager.getCredentialsByTargetId("foo"), Matchers.nullValue()); + } + + @Test + void canAddThenGet() throws Exception { + String targetId = "foo"; + String matchExpression = String.format("target.connectUrl == \"%s\"", targetId); + + String filename = + String.format( + "%s.json", + base32.encodeToString(matchExpression.getBytes(StandardCharsets.UTF_8))); + Path path = Mockito.mock(Path.class); + Mockito.when(credentialsDir.resolve(filename)).thenReturn(path); + + String username = "user"; + String password = "pass"; + Credentials credentials = new Credentials(username, password); + + ServiceRef serviceRef = new ServiceRef(new URI(targetId), "foo"); + Mockito.when(matchExpressionEvaluator.applies(matchExpression, serviceRef)) + .thenReturn(true); + + credentialsManager.addCredentials(matchExpression, credentials); + + ArgumentCaptor contentsCaptor = ArgumentCaptor.forClass(String.class); + + InOrder inOrder = Mockito.inOrder(fs, credentialsDir); + inOrder.verify(credentialsDir).resolve(filename); + inOrder.verify(fs) + .writeString( + Mockito.eq(path), + contentsCaptor.capture(), + Mockito.eq(StandardOpenOption.WRITE), + Mockito.eq(StandardOpenOption.CREATE), + Mockito.eq(StandardOpenOption.TRUNCATE_EXISTING)); + inOrder.verify(fs) + .setPosixFilePermissions( + path, + Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); + Mockito.verifyNoMoreInteractions(fs); + + String newContents = contentsCaptor.getValue(); + + JsonObject json = gson.fromJson(newContents, JsonObject.class); + MatcherAssert.assertThat( + json.getAsJsonPrimitive("matchExpression").getAsString(), + Matchers.equalTo(matchExpression)); + JsonObject foundCredentials = json.getAsJsonObject("credentials"); + String foundUsername = foundCredentials.getAsJsonPrimitive("username").getAsString(); + String foundPassword = foundCredentials.getAsJsonPrimitive("password").getAsString(); + MatcherAssert.assertThat(foundUsername, Matchers.equalTo(username)); + MatcherAssert.assertThat(foundPassword, Matchers.equalTo(password)); + + Credentials found = credentialsManager.getCredentials(serviceRef); + MatcherAssert.assertThat(found.getUsername(), Matchers.equalTo(username)); + MatcherAssert.assertThat(found.getPassword(), Matchers.equalTo(password)); + } + + @Test + void canAddThenRemove() throws Exception { + String targetId = "foo"; + String matchExpression = String.format("target.connectUrl == \"%s\"", targetId); + + String filename = + String.format( + "%s.json", + base32.encodeToString(matchExpression.getBytes(StandardCharsets.UTF_8))); + Path path = Mockito.mock(Path.class); + Mockito.when(credentialsDir.resolve(filename)).thenReturn(path); + + String username = "user"; + String password = "pass"; + Credentials credentials = new Credentials(username, password); + + Assertions.assertFalse(credentialsManager.removeCredentials(matchExpression)); + + credentialsManager.addCredentials(matchExpression, credentials); + + Assertions.assertTrue(credentialsManager.removeCredentials(matchExpression)); + } + + @Test + void addedCredentialsCanMatchMultipleTargets() throws Exception { + ServiceRef target1 = new ServiceRef(new URI("target1"), "target1Alias"); + ServiceRef target2 = new ServiceRef(new URI("target2"), "target2Alias"); + ServiceRef target3 = new ServiceRef(new URI("target3"), "target3Alias"); + ServiceRef target4 = new ServiceRef(new URI("target4"), "target4Alias"); + + Mockito.when(platformClient.listDiscoverableServices()) + .thenReturn(List.of(target1, target2, target3, target4)); + + String matchExpression = "some expression"; + String username = "user"; + String password = "pass"; + Credentials credentials = new Credentials(username, password); + + Mockito.when(matchExpressionEvaluator.applies(Mockito.eq(matchExpression), Mockito.any())) + .thenAnswer( + new Answer() { + @Override + public Boolean answer(InvocationOnMock invocation) throws Throwable { + ServiceRef sr = (ServiceRef) invocation.getArgument(1); + String alias = sr.getAlias().orElseThrow(); + return Set.of("target1Alias", "target2Alias").contains(alias); + } + }); + + credentialsManager.addCredentials(matchExpression, credentials); + + MatcherAssert.assertThat( + credentialsManager.getCredentials(target1), Matchers.equalTo(credentials)); + MatcherAssert.assertThat( + credentialsManager.getCredentials(target2), Matchers.equalTo(credentials)); + MatcherAssert.assertThat(credentialsManager.getCredentials(target3), Matchers.nullValue()); + MatcherAssert.assertThat(credentialsManager.getCredentials(target4), Matchers.nullValue()); + + MatcherAssert.assertThat( + credentialsManager.getCredentialsByTargetId("target1"), + Matchers.equalTo(credentials)); + MatcherAssert.assertThat( + credentialsManager.getCredentialsByTargetId("target2"), + Matchers.equalTo(credentials)); + MatcherAssert.assertThat( + credentialsManager.getCredentialsByTargetId("target3"), Matchers.nullValue()); + MatcherAssert.assertThat( + credentialsManager.getCredentialsByTargetId("target4"), Matchers.nullValue()); + } + + @Test + void canQueryDiscoveredTargetsWithConfiguredCredentials() throws Exception { + ServiceRef target1 = new ServiceRef(new URI("target1"), "target1Alias"); + ServiceRef target2 = new ServiceRef(new URI("target2"), "target2Alias"); + ServiceRef target3 = new ServiceRef(new URI("target3"), "target3Alias"); + ServiceRef target4 = new ServiceRef(new URI("target4"), "target4Alias"); + + Mockito.when(platformClient.listDiscoverableServices()) + .thenReturn(List.of(target1, target2, target3, target4)); + + String matchExpression = "some expression"; + String username = "user"; + String password = "pass"; + Credentials credentials = new Credentials(username, password); + + Mockito.when(matchExpressionEvaluator.applies(Mockito.eq(matchExpression), Mockito.any())) + .thenAnswer( + new Answer() { + @Override + public Boolean answer(InvocationOnMock invocation) throws Throwable { + ServiceRef sr = (ServiceRef) invocation.getArgument(1); + String alias = sr.getAlias().orElseThrow(); + return Set.of("target1Alias", "target2Alias").contains(alias); + } + }); + + credentialsManager.addCredentials(matchExpression, credentials); + + MatcherAssert.assertThat( + credentialsManager.getServiceRefsWithCredentials(), + Matchers.equalTo(List.of(target1, target2))); + } + + @Test + void canQueryExpressionsWithMatchingTargets() throws Exception { + ServiceRef target1 = new ServiceRef(new URI("target1"), "target1Alias"); + ServiceRef target2 = new ServiceRef(new URI("target2"), "target2Alias"); + ServiceRef target3 = new ServiceRef(new URI("target3"), "target3Alias"); + ServiceRef target4 = new ServiceRef(new URI("target4"), "target4Alias"); + + Mockito.when(platformClient.listDiscoverableServices()) + .thenReturn(List.of(target1, target2, target3, target4)); + + String matchExpression = "some expression"; + String username = "user"; + String password = "pass"; + Credentials credentials = new Credentials(username, password); + + Mockito.when(matchExpressionEvaluator.applies(Mockito.eq(matchExpression), Mockito.any())) + .thenAnswer( + new Answer() { + @Override + public Boolean answer(InvocationOnMock invocation) throws Throwable { + ServiceRef sr = (ServiceRef) invocation.getArgument(1); + String alias = sr.getAlias().orElseThrow(); + return Set.of("target1Alias", "target2Alias").contains(alias); + } + }); + + credentialsManager.addCredentials(matchExpression, credentials); + + MatchedCredentials matchedCredentials = + new MatchedCredentials(matchExpression, Set.of(target1, target2)); + + MatcherAssert.assertThat( + credentialsManager.getMatchExpressionsWithMatchedTargets(), + Matchers.equalTo(List.of(matchedCredentials))); + } + + @Test + void canListMatchExpressions() throws Exception { + String matchExpression = "some expression"; + String username = "user"; + String password = "pass"; + Credentials credentials = new Credentials(username, password); + + credentialsManager.addCredentials(matchExpression, credentials); + + MatcherAssert.assertThat( + credentialsManager.getMatchExpressions(), + Matchers.equalTo(Set.of(matchExpression))); + } + + @Nested + class Migration { + + @Test + void doesNothingIfNoFiles() throws Exception { + Mockito.when(fs.listDirectoryChildren(Mockito.any())).thenReturn(List.of()); + + Mockito.verifyNoInteractions(fs); + + credentialsManager.migrate(); + + Mockito.verify(fs).listDirectoryChildren(credentialsDir); + Mockito.verifyNoMoreInteractions(fs); + } + + @Test + void doesNothingIfAllFilesInNewFormat() throws Exception { + String matchExpression = "target.connectUrl == \"foo\""; + String filename = + String.format( + "%s.json", + base32.encodeToString( + matchExpression.getBytes(StandardCharsets.UTF_8))); + Mockito.when(fs.listDirectoryChildren(Mockito.any())).thenReturn(List.of(filename)); + + Path path = Mockito.mock(Path.class); + Mockito.when(credentialsDir.resolve(filename)).thenReturn(path); + + String contents = + gson.toJson( + Map.of( + "matchExpression", + matchExpression, + "credentials", + Map.of("username", "user", "password", "pass"))); + Mockito.when(fs.readFile(path)) + .thenReturn(new BufferedReader(new StringReader(contents))); + + credentialsManager.migrate(); + + InOrder inOrder = Mockito.inOrder(fs, credentialsDir); + inOrder.verify(fs).listDirectoryChildren(credentialsDir); + inOrder.verify(credentialsDir).resolve(filename); + inOrder.verify(fs).readFile(path); + Mockito.verifyNoMoreInteractions(fs); + } + + @Test + void migratesOldFormatToNewFormat() throws Exception { + String targetId = "foo"; + String matchExpression = String.format("target.connectUrl == \"%s\"", targetId); + + String originalFilename = + String.format( + "%s.json", + base32.encodeToString(targetId.getBytes(StandardCharsets.UTF_8))); + Mockito.when(fs.listDirectoryChildren(Mockito.any())) + .thenReturn(List.of(originalFilename)); + + String newFilename = + String.format( + "%s.json", + base32.encodeToString( + matchExpression.getBytes(StandardCharsets.UTF_8))); + + Path originalPath = Mockito.mock(Path.class); + Path newPath = Mockito.mock(Path.class); + Mockito.when(credentialsDir.resolve(originalFilename)).thenReturn(originalPath); + Mockito.when(credentialsDir.resolve(newFilename)).thenReturn(newPath); + + String username = "user"; + String password = "pass"; + + String oldContents = + gson.toJson( + Map.of( + "targetId", + targetId, + "credentials", + Map.of("username", username, "password", password))); + Mockito.when(fs.readFile(originalPath)) + .thenReturn(new BufferedReader(new StringReader(oldContents))); + + credentialsManager.migrate(); + + ArgumentCaptor contentsCaptor = ArgumentCaptor.forClass(String.class); + + Mockito.verify(fs).listDirectoryChildren(credentialsDir); + Mockito.verify(credentialsDir).resolve(originalFilename); + Mockito.verify(fs).readFile(originalPath); + Mockito.verify(fs) + .writeString( + Mockito.eq(newPath), + contentsCaptor.capture(), + Mockito.eq(StandardOpenOption.WRITE), + Mockito.eq(StandardOpenOption.CREATE), + Mockito.eq(StandardOpenOption.TRUNCATE_EXISTING)); + Mockito.verify(fs) + .setPosixFilePermissions( + newPath, + Set.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE)); + Mockito.verify(fs).deleteIfExists(originalPath); + Mockito.verifyNoMoreInteractions(fs); + + String newContents = contentsCaptor.getValue(); + JsonObject json = gson.fromJson(newContents, JsonObject.class); + MatcherAssert.assertThat( + json.getAsJsonPrimitive("matchExpression").getAsString(), + Matchers.equalTo(matchExpression)); + JsonObject credentials = json.getAsJsonObject("credentials"); + String foundUsername = credentials.getAsJsonPrimitive("username").getAsString(); + String foundPassword = credentials.getAsJsonPrimitive("password").getAsString(); + MatcherAssert.assertThat(foundUsername, Matchers.equalTo(username)); + MatcherAssert.assertThat(foundPassword, Matchers.equalTo(password)); + } + } + + @Nested + class Loading { + @Test + void loadingFilesMakesContentsAvailable() throws Exception { + String targetId = "foo"; + String matchExpression = String.format("target.connectUrl == \"%s\"", targetId); + String filename = + String.format( + "%s.json", + base32.encodeToString( + matchExpression.getBytes(StandardCharsets.UTF_8))); + Mockito.when(fs.listDirectoryChildren(Mockito.any())).thenReturn(List.of(filename)); + + Path path = Mockito.mock(Path.class); + Mockito.when(credentialsDir.resolve(filename)).thenReturn(path); + + String username = "user"; + String password = "pass"; + String contents = + gson.toJson( + Map.of( + "matchExpression", + matchExpression, + "credentials", + Map.of("username", username, "password", password))); + Mockito.when(fs.readFile(path)) + .thenReturn(new BufferedReader(new StringReader(contents))); + + ServiceRef serviceRef = new ServiceRef(new URI(targetId), "foo"); + Mockito.when(platformClient.listDiscoverableServices()).thenReturn(List.of(serviceRef)); + Mockito.when(matchExpressionEvaluator.applies(matchExpression, serviceRef)) + .thenReturn(true); + + credentialsManager.load(); + + InOrder inOrder = Mockito.inOrder(fs, credentialsDir); + inOrder.verify(fs).listDirectoryChildren(credentialsDir); + inOrder.verify(credentialsDir).resolve(filename); + inOrder.verify(fs).readFile(path); + Mockito.verifyNoMoreInteractions(fs); + + Credentials found = credentialsManager.getCredentialsByTargetId(targetId); + MatcherAssert.assertThat(found.getUsername(), Matchers.equalTo(username)); + MatcherAssert.assertThat(found.getPassword(), Matchers.equalTo(password)); + } + + @Test + void loadingFilesMakesContentsAvailable2() throws Exception { + String targetId = "foo"; + String matchExpression = String.format("target.connectUrl == \"%s\"", targetId); + String filename = + String.format( + "%s.json", + base32.encodeToString( + matchExpression.getBytes(StandardCharsets.UTF_8))); + Mockito.when(fs.listDirectoryChildren(Mockito.any())).thenReturn(List.of(filename)); + + Path path = Mockito.mock(Path.class); + Mockito.when(credentialsDir.resolve(filename)).thenReturn(path); + + String username = "user"; + String password = "pass"; + String contents = + gson.toJson( + Map.of( + "matchExpression", + matchExpression, + "credentials", + Map.of("username", username, "password", password))); + Mockito.when(fs.readFile(path)) + .thenReturn(new BufferedReader(new StringReader(contents))); + + ServiceRef serviceRef = new ServiceRef(new URI(targetId), "foo"); + Mockito.when(matchExpressionEvaluator.applies(matchExpression, serviceRef)) + .thenReturn(true); + + credentialsManager.load(); + + InOrder inOrder = Mockito.inOrder(fs, credentialsDir); + inOrder.verify(fs).listDirectoryChildren(credentialsDir); + inOrder.verify(credentialsDir).resolve(filename); + inOrder.verify(fs).readFile(path); + Mockito.verifyNoMoreInteractions(fs); + + Credentials found = credentialsManager.getCredentials(serviceRef); + MatcherAssert.assertThat(found.getUsername(), Matchers.equalTo(username)); + MatcherAssert.assertThat(found.getPassword(), Matchers.equalTo(password)); + } + } +} diff --git a/src/test/java/io/cryostat/net/web/http/api/v2/TargetCredentialsDeleteHandlerTest.java b/src/test/java/io/cryostat/net/web/http/api/v2/TargetCredentialsDeleteHandlerTest.java index 8db0cddebd..f2a5580c35 100644 --- a/src/test/java/io/cryostat/net/web/http/api/v2/TargetCredentialsDeleteHandlerTest.java +++ b/src/test/java/io/cryostat/net/web/http/api/v2/TargetCredentialsDeleteHandlerTest.java @@ -152,6 +152,7 @@ class RequestExecutions { @Test void shouldRespond200OnSuccess() throws Exception { String targetId = "fooTarget"; + String matchExpression = String.format("target.connectUrl == \"%s\"", targetId); Mockito.when(requestParams.getPathParams()).thenReturn(Map.of("targetId", targetId)); Mockito.when(credentialsManager.removeCredentials(Mockito.anyString())) .thenReturn(true); @@ -159,12 +160,12 @@ void shouldRespond200OnSuccess() throws Exception { IntermediateResponse response = handler.handle(requestParams); MatcherAssert.assertThat(response.getStatusCode(), Matchers.equalTo(200)); - Mockito.verify(credentialsManager).removeCredentials(targetId); + Mockito.verify(credentialsManager).removeCredentials(matchExpression); Mockito.verify(notificationFactory).createBuilder(); Mockito.verify(notificationBuilder).metaCategory("TargetCredentialsDeleted"); Mockito.verify(notificationBuilder).metaType(HttpMimeType.JSON); - Mockito.verify(notificationBuilder).message(Map.of("target", targetId)); + Mockito.verify(notificationBuilder).message(Map.of("target", matchExpression)); Mockito.verify(notificationBuilder).build(); Mockito.verify(notification).send(); } @@ -172,6 +173,7 @@ void shouldRespond200OnSuccess() throws Exception { @Test void shouldRespond404OnFailure() throws Exception { String targetId = "fooTarget"; + String matchExpression = String.format("target.connectUrl == \"%s\"", targetId); Mockito.when(requestParams.getPathParams()).thenReturn(Map.of("targetId", targetId)); Mockito.when(credentialsManager.removeCredentials(Mockito.anyString())) .thenReturn(false); @@ -179,12 +181,13 @@ void shouldRespond404OnFailure() throws Exception { IntermediateResponse response = handler.handle(requestParams); MatcherAssert.assertThat(response.getStatusCode(), Matchers.equalTo(404)); - Mockito.verify(credentialsManager).removeCredentials(targetId); + Mockito.verify(credentialsManager).removeCredentials(matchExpression); } @Test void shouldWrapIOExceptions() throws Exception { String targetId = "fooTarget"; + String matchExpression = String.format("target.connectUrl == \"%s\"", targetId); Mockito.when(requestParams.getPathParams()).thenReturn(Map.of("targetId", targetId)); Mockito.when(credentialsManager.removeCredentials(Mockito.anyString())) .thenThrow(IOException.class); @@ -195,7 +198,7 @@ void shouldWrapIOExceptions() throws Exception { MatcherAssert.assertThat(ex.getStatusCode(), Matchers.equalTo(500)); MatcherAssert.assertThat(ex.getCause(), Matchers.instanceOf(IOException.class)); - Mockito.verify(credentialsManager).removeCredentials(targetId); + Mockito.verify(credentialsManager).removeCredentials(matchExpression); } } } diff --git a/src/test/java/io/cryostat/net/web/http/api/v2/TargetCredentialsGetHandlerTest.java b/src/test/java/io/cryostat/net/web/http/api/v2/TargetCredentialsGetHandlerTest.java index f0fed2ad5b..cb4ffa6227 100644 --- a/src/test/java/io/cryostat/net/web/http/api/v2/TargetCredentialsGetHandlerTest.java +++ b/src/test/java/io/cryostat/net/web/http/api/v2/TargetCredentialsGetHandlerTest.java @@ -143,7 +143,7 @@ void shouldDelegateToCredentialsManager() throws Exception { MatcherAssert.assertThat(response.getStatusCode(), Matchers.equalTo(200)); MatcherAssert.assertThat(response.getBody(), Matchers.equalTo(new ArrayList<>())); - Mockito.verify(credentialsManager).getCredentialKeys(); + Mockito.verify(credentialsManager).getServiceRefsWithCredentials(); } } } diff --git a/src/test/java/io/cryostat/net/web/http/api/v2/TargetCredentialsPostHandlerTest.java b/src/test/java/io/cryostat/net/web/http/api/v2/TargetCredentialsPostHandlerTest.java index 9b5b77a8ea..ac32a27bf9 100644 --- a/src/test/java/io/cryostat/net/web/http/api/v2/TargetCredentialsPostHandlerTest.java +++ b/src/test/java/io/cryostat/net/web/http/api/v2/TargetCredentialsPostHandlerTest.java @@ -230,6 +230,7 @@ void shouldRespond400WhenFormEmpty() throws Exception { @Test void shouldDelegateToCredentialsManager() throws Exception { String targetId = "fooTarget"; + String matchExpression = String.format("target.connectUrl == \"%s\"", targetId); String username = "adminuser"; String password = "abc123"; Mockito.when(requestParams.getPathParams()).thenReturn(Map.of("targetId", targetId)); @@ -243,12 +244,12 @@ void shouldDelegateToCredentialsManager() throws Exception { MatcherAssert.assertThat(response.getStatusCode(), Matchers.equalTo(200)); MatcherAssert.assertThat(response.getBody(), Matchers.nullValue()); Mockito.verify(credentialsManager) - .addCredentials(targetId, new Credentials(username, password)); + .addCredentials(matchExpression, new Credentials(username, password)); Mockito.verify(notificationFactory).createBuilder(); Mockito.verify(notificationBuilder).metaCategory("TargetCredentialsStored"); Mockito.verify(notificationBuilder).metaType(HttpMimeType.JSON); - Mockito.verify(notificationBuilder).message(Map.of("target", targetId)); + Mockito.verify(notificationBuilder).message(Map.of("target", matchExpression)); Mockito.verify(notificationBuilder).build(); Mockito.verify(notification).send(); } diff --git a/src/test/java/io/cryostat/rules/RuleMatcherTest.java b/src/test/java/io/cryostat/rules/MatchExpressionEvaluatorTest.java similarity index 76% rename from src/test/java/io/cryostat/rules/RuleMatcherTest.java rename to src/test/java/io/cryostat/rules/MatchExpressionEvaluatorTest.java index e42279fbae..baa2c9fbf8 100644 --- a/src/test/java/io/cryostat/rules/RuleMatcherTest.java +++ b/src/test/java/io/cryostat/rules/MatchExpressionEvaluatorTest.java @@ -46,6 +46,7 @@ import javax.script.Bindings; import javax.script.ScriptException; +import io.cryostat.MainModule; import io.cryostat.platform.ServiceRef; import io.cryostat.platform.ServiceRef.AnnotationKey; @@ -64,9 +65,9 @@ import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) -class RuleMatcherTest { +class MatchExpressionEvaluatorTest { - RuleMatcher ruleMatcher; + MatchExpressionEvaluator ruleMatcher; @Mock ServiceRef serviceRef; URI serviceUri; @@ -77,7 +78,7 @@ class RuleMatcherTest { @BeforeEach void setup() throws Exception { - this.ruleMatcher = new RuleMatcher(); + this.ruleMatcher = new MatchExpressionEvaluator(MainModule.provideScriptEngine()); this.serviceUri = new URI("service:jmx:rmi:///jndi/rmi://cryostat:9091/jmxrmi"); this.alias = "someAlias"; @@ -117,13 +118,15 @@ void targetShouldHaveExpectedKeys() { @Test void targetShouldHaveServiceUriAsUri() { URI uri = (URI) ((Map) bindings.get("target")).get("connectUrl"); - MatcherAssert.assertThat(uri, Matchers.equalTo(RuleMatcherTest.this.serviceUri)); + MatcherAssert.assertThat( + uri, Matchers.equalTo(MatchExpressionEvaluatorTest.this.serviceUri)); } @Test void targetShouldHaveAliasAsString() { String alias = (String) (((Map) bindings.get("target")).get("alias")); - MatcherAssert.assertThat(alias, Matchers.equalTo(RuleMatcherTest.this.alias)); + MatcherAssert.assertThat( + alias, Matchers.equalTo(MatchExpressionEvaluatorTest.this.alias)); } @Test @@ -131,7 +134,8 @@ void targetShouldHaveLabels() { Map labels = (Map) (((Map) bindings.get("target")).get("labels")); - MatcherAssert.assertThat(labels, Matchers.equalTo(RuleMatcherTest.this.labels)); + MatcherAssert.assertThat( + labels, Matchers.equalTo(MatchExpressionEvaluatorTest.this.labels)); } @Test @@ -153,7 +157,8 @@ void targetShouldHavePlatformAnnotations() { .get("annotations")) .get("platform"); MatcherAssert.assertThat( - annotations, Matchers.equalTo(RuleMatcherTest.this.platformAnnotations)); + annotations, + Matchers.equalTo(MatchExpressionEvaluatorTest.this.platformAnnotations)); } @Test @@ -166,7 +171,7 @@ void targetShouldHaveCryostatAnnotations() { .get("cryostat"); Map expected = new HashMap<>(); for (Map.Entry entry : - RuleMatcherTest.this.cryostatAnnotations.entrySet()) { + MatchExpressionEvaluatorTest.this.cryostatAnnotations.entrySet()) { expected.put(entry.getKey().name(), entry.getValue()); } MatcherAssert.assertThat(annotations, Matchers.equalTo(expected)); @@ -176,80 +181,69 @@ void targetShouldHaveCryostatAnnotations() { @Nested class ExpressionEvaluation { - @Mock Rule rule; - @Test void shouldMatchOnTrue() throws Exception { - Mockito.when(rule.getMatchExpression()).thenReturn("true"); - Assertions.assertTrue(ruleMatcher.applies(rule, serviceRef)); + Assertions.assertTrue(ruleMatcher.applies("true", serviceRef)); } @Test void shouldNotMatchOnFalse() throws Exception { - Mockito.when(rule.getMatchExpression()).thenReturn("false"); - Assertions.assertFalse(ruleMatcher.applies(rule, serviceRef)); + Assertions.assertFalse(ruleMatcher.applies("false", serviceRef)); } @Test void shouldMatchOnAlias() throws Exception { - Mockito.when(rule.getMatchExpression()) - .thenReturn(String.format("target.alias == '%s'", RuleMatcherTest.this.alias)); - Assertions.assertTrue(ruleMatcher.applies(rule, serviceRef)); + String expr = + String.format("target.alias == '%s'", MatchExpressionEvaluatorTest.this.alias); + Assertions.assertTrue(ruleMatcher.applies(expr, serviceRef)); } @ParameterizedTest @ValueSource(strings = {"foo", "somethingelse", "true"}) @NullAndEmptySource void shouldNotMatchOnWrongAlias(String s) throws Exception { - Mockito.when(rule.getMatchExpression()) - .thenReturn(String.format("target.alias == '%s'", s)); - Assertions.assertFalse(ruleMatcher.applies(rule, serviceRef)); + String expr = String.format("target.alias == '%s'", s); + Assertions.assertFalse(ruleMatcher.applies(expr, serviceRef)); } @Test void shouldMatchOnConnectUrl() throws Exception { - Mockito.when(rule.getMatchExpression()) - .thenReturn( - String.format( - "target.connectUrl == '%s'", - RuleMatcherTest.this.serviceUri.toString())); - Assertions.assertTrue(ruleMatcher.applies(rule, serviceRef)); + String expr = + String.format( + "target.connectUrl == '%s'", + MatchExpressionEvaluatorTest.this.serviceUri.toString()); + Assertions.assertTrue(ruleMatcher.applies(expr, serviceRef)); } @Test void shouldMatchOnLabels() throws Exception { - Mockito.when(rule.getMatchExpression()) - .thenReturn("target.labels.label1 == 'someLabel'"); - Assertions.assertTrue(ruleMatcher.applies(rule, serviceRef)); + String expr = "target.labels.label1 == 'someLabel'"; + Assertions.assertTrue(ruleMatcher.applies(expr, serviceRef)); } @Test void shouldNotMatchOnMissingLabels() throws Exception { - Mockito.when(rule.getMatchExpression()) - .thenReturn("target.labels.label2 == 'someLabel'"); - Assertions.assertFalse(ruleMatcher.applies(rule, serviceRef)); + String expr = "target.labels.label2 == 'someLabel'"; + Assertions.assertFalse(ruleMatcher.applies(expr, serviceRef)); } @Test void shouldMatchOnPlatformAnnotations() throws Exception { - Mockito.when(rule.getMatchExpression()) - .thenReturn("target.annotations.platform.annotation1 == 'someAnnotation'"); - Assertions.assertTrue(ruleMatcher.applies(rule, serviceRef)); + String expr = "target.annotations.platform.annotation1 == 'someAnnotation'"; + Assertions.assertTrue(ruleMatcher.applies(expr, serviceRef)); } @Test void shouldMatchOnCryostatAnnotations() throws Exception { - Mockito.when(rule.getMatchExpression()) - .thenReturn("target.annotations.cryostat.JAVA_MAIN == 'io.cryostat.Cryostat'"); - Assertions.assertTrue(ruleMatcher.applies(rule, serviceRef)); + String expr = "target.annotations.cryostat.JAVA_MAIN == 'io.cryostat.Cryostat'"; + Assertions.assertTrue(ruleMatcher.applies(expr, serviceRef)); } @ParameterizedTest @ValueSource(strings = {"1", "null", "target.alias", "\"a string\""}) void shouldThrowExceptionOnNonBooleanExpressionEval(String expr) throws Exception { - Mockito.when(rule.getMatchExpression()).thenReturn(expr); Assertions.assertThrows( - ScriptException.class, () -> ruleMatcher.applies(rule, serviceRef)); + ScriptException.class, () -> ruleMatcher.applies(expr, serviceRef)); } } } diff --git a/src/test/java/io/cryostat/rules/RuleProcessorTest.java b/src/test/java/io/cryostat/rules/RuleProcessorTest.java index cade196e92..681412f70b 100644 --- a/src/test/java/io/cryostat/rules/RuleProcessorTest.java +++ b/src/test/java/io/cryostat/rules/RuleProcessorTest.java @@ -168,7 +168,7 @@ void testSuccessfulRuleActivationWithCredentials() throws Exception { ServiceRef serviceRef = new ServiceRef(new URI(jmxUrl), "com.example.App"); Credentials credentials = new Credentials("foouser", "barpassword"); - Mockito.when(credentialsManager.getCredentials(jmxUrl)).thenReturn(credentials); + Mockito.when(credentialsManager.getCredentials(serviceRef)).thenReturn(credentials); TargetDiscoveryEvent tde = new TargetDiscoveryEvent(EventKind.FOUND, serviceRef); @@ -268,7 +268,7 @@ void testSuccessfulArchiverRuleActivationWithCredentials() throws Exception { ServiceRef serviceRef = new ServiceRef(new URI(jmxUrl), "com.example.App"); Credentials credentials = new Credentials("foouser", "barpassword"); - Mockito.when(credentialsManager.getCredentials(jmxUrl)).thenReturn(credentials); + Mockito.when(credentialsManager.getCredentials(serviceRef)).thenReturn(credentials); TargetDiscoveryEvent tde = new TargetDiscoveryEvent(EventKind.FOUND, serviceRef); @@ -316,7 +316,7 @@ void testTaskCancellationOnFailure() throws Exception { ServiceRef serviceRef = new ServiceRef(new URI(jmxUrl), "com.example.App"); Credentials credentials = new Credentials("foouser", "barpassword"); - Mockito.when(credentialsManager.getCredentials(jmxUrl)).thenReturn(credentials); + Mockito.when(credentialsManager.getCredentials(serviceRef)).thenReturn(credentials); TargetDiscoveryEvent tde = new TargetDiscoveryEvent(EventKind.FOUND, serviceRef); diff --git a/src/test/java/io/cryostat/rules/RuleRegistryTest.java b/src/test/java/io/cryostat/rules/RuleRegistryTest.java index a381a8709f..8117b07fb6 100644 --- a/src/test/java/io/cryostat/rules/RuleRegistryTest.java +++ b/src/test/java/io/cryostat/rules/RuleRegistryTest.java @@ -73,7 +73,7 @@ class RuleRegistryTest { RuleRegistry registry; @Mock Path rulesDir; - @Mock RuleMatcher ruleMatcher; + @Mock MatchExpressionEvaluator matchExpressionEvaluator; @Mock FileSystem fs; @Mock Logger logger; Gson gson = Mockito.spy(MainModule.provideGson(logger)); @@ -84,7 +84,7 @@ class RuleRegistryTest { @BeforeEach void setup() throws Exception { - this.registry = new RuleRegistry(rulesDir, ruleMatcher, fs, gson, logger); + this.registry = new RuleRegistry(rulesDir, matchExpressionEvaluator, fs, gson, logger); this.testRule = new Rule.Builder() .name("test rule") @@ -240,7 +240,8 @@ void testGetRulesByServiceRef() throws Exception { Mockito.when(fs.listDirectoryChildren(rulesDir)).thenReturn(List.of("test_rule.json")); Mockito.when(fs.readFile(rulePath)).thenReturn(fileReader); - Mockito.when(ruleMatcher.applies(Mockito.any(), Mockito.any())).thenReturn(true); + Mockito.when(matchExpressionEvaluator.applies(Mockito.any(), Mockito.any())) + .thenReturn(true); registry.addRule(testRule); diff --git a/src/test/java/itest/CredentialsIT.java b/src/test/java/itest/CredentialsIT.java index f75269bbc2..d426a0e8b4 100644 --- a/src/test/java/itest/CredentialsIT.java +++ b/src/test/java/itest/CredentialsIT.java @@ -44,7 +44,6 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; import io.cryostat.MainModule; import io.cryostat.core.log.Logger; @@ -55,7 +54,6 @@ import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; import io.vertx.core.MultiMap; -import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import io.vertx.ext.web.handler.HttpException; import itest.bases.ExternalTargetsTest; @@ -224,80 +222,6 @@ void testAddCredentialsThrowsOnBlankPassword(String password) throws Exception { MatcherAssert.assertThat(ex.getCause().getMessage(), Matchers.equalTo("Bad Request")); } - @Test - void testInvalidCredentialsRemovedOnConnectionFailure() throws Exception { - CONTAINERS.add( - Podman.run( - new Podman.ImageSpec( - "quay.io/andrewazores/vertx-fib-demo:0.6.0", - Map.of("JMX_PORT", "9094", "USE_AUTH", "true")))); - CompletableFuture.allOf( - CONTAINERS.stream() - .map(id -> Podman.waitForContainerState(id, "running")) - .collect(Collectors.toList()) - .toArray(new CompletableFuture[0])) - .join(); - Thread.sleep(10_000L); // wait for JDP to discover new container(s) - - // Post invalid credentials for the new pod - CompletableFuture postResponse = new CompletableFuture<>(); - MultiMap form = MultiMap.caseInsensitiveMultiMap(); - form.add("username", "admin"); - form.add("password", "invalidPassword"); - webClient - .post(String.format("/api/v2/targets/%s/credentials", Podman.POD_NAME + ":9094")) - .sendForm( - form, - ar -> { - if (assertRequestStatus(ar, postResponse)) { - postResponse.complete(ar.result().bodyAsJsonObject()); - } - }); - JsonObject expectedResponse = - new JsonObject( - Map.of( - "meta", - Map.of("type", HttpMimeType.PLAINTEXT.mime(), "status", "OK"), - "data", - NULL_RESULT)); - MatcherAssert.assertThat( - postResponse.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS), - Matchers.equalTo(expectedResponse)); - - // Use the invalid stored credentials to GET recordings - CompletableFuture response = new CompletableFuture<>(); - webClient - .get(String.format("/api/v1/targets/%s/recordings", Podman.POD_NAME + ":9094")) - .send( - ar -> { - if (assertRequestStatus(ar, response)) { - response.complete(ar.result().bodyAsJsonArray()); - } - }); - ExecutionException ee = - Assertions.assertThrows( - ExecutionException.class, - () -> response.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS)); - MatcherAssert.assertThat( - ((HttpException) ee.getCause()).getStatusCode(), Matchers.equalTo(427)); - - // Confirm invalid credentials automatically deleted by attempting to delete them - CompletableFuture deleteResponse = new CompletableFuture<>(); - webClient - .delete(String.format("/api/v2/targets/%s/credentials", Podman.POD_NAME + ":9094")) - .send( - ar -> { - assertRequestStatus(ar, deleteResponse); - }); - ExecutionException ex = - Assertions.assertThrows( - ExecutionException.class, - () -> deleteResponse.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS)); - MatcherAssert.assertThat( - ((HttpException) ex.getCause()).getStatusCode(), Matchers.equalTo(404)); - MatcherAssert.assertThat(ex.getCause().getMessage(), Matchers.equalTo("Not Found")); - } - @Test void testGetTargetCredentialsReturnsTargetList() throws Exception { // Get target credentials list should be empty at first