From 2c34743ba9be1a9f8de04a6f5deecd6c330e3fa1 Mon Sep 17 00:00:00 2001 From: nscuro Date: Sat, 27 Jul 2024 21:50:24 +0200 Subject: [PATCH] Introduce plugin system to deal with provider config and lifecycle Signed-off-by: nscuro --- .../plugin/ConfigRegistry.java | 106 +++++++ .../org/dependencytrack/plugin/Plugin.java | 46 +++ .../plugin/PluginInitializer.java | 47 +++ .../dependencytrack/plugin/PluginManager.java | 286 ++++++++++++++++++ .../org/dependencytrack/plugin/Provider.java | 34 +++ .../plugin/ProviderFactory.java | 60 ++++ .../resources/v1/PluginResource.java | 79 +++++ .../v1/vo/LoadedPluginListResponseItem.java | 35 +++ .../org.dependencytrack.plugin.Plugin | 0 src/main/webapp/WEB-INF/web.xml | 6 +- .../PersistenceCapableTest.java | 5 + .../org/dependencytrack/ResourceTest.java | 6 + .../plugin/ConfigRegistryTest.java | 73 +++++ .../dependencytrack/plugin/DummyPlugin.java | 43 +++ .../dependencytrack/plugin/DummyProvider.java | 22 ++ .../plugin/DummyProviderFactory.java | 22 ++ .../plugin/PluginManagerTest.java | 77 +++++ .../plugin/PluginManagerTestUtil.java | 31 ++ .../plugin/TestDummyProviderFactory.java | 42 +++ .../resources/v1/PluginResourceTest.java | 61 ++++ ...ependencytrack.plugin.DummyProviderFactory | 1 + .../org.dependencytrack.plugin.Plugin | 1 + 22 files changed, 1080 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/dependencytrack/plugin/ConfigRegistry.java create mode 100644 src/main/java/org/dependencytrack/plugin/Plugin.java create mode 100644 src/main/java/org/dependencytrack/plugin/PluginInitializer.java create mode 100644 src/main/java/org/dependencytrack/plugin/PluginManager.java create mode 100644 src/main/java/org/dependencytrack/plugin/Provider.java create mode 100644 src/main/java/org/dependencytrack/plugin/ProviderFactory.java create mode 100644 src/main/java/org/dependencytrack/resources/v1/PluginResource.java create mode 100644 src/main/java/org/dependencytrack/resources/v1/vo/LoadedPluginListResponseItem.java create mode 100644 src/main/resources/META-INF/services/org.dependencytrack.plugin.Plugin create mode 100644 src/test/java/org/dependencytrack/plugin/ConfigRegistryTest.java create mode 100644 src/test/java/org/dependencytrack/plugin/DummyPlugin.java create mode 100644 src/test/java/org/dependencytrack/plugin/DummyProvider.java create mode 100644 src/test/java/org/dependencytrack/plugin/DummyProviderFactory.java create mode 100644 src/test/java/org/dependencytrack/plugin/PluginManagerTest.java create mode 100644 src/test/java/org/dependencytrack/plugin/PluginManagerTestUtil.java create mode 100644 src/test/java/org/dependencytrack/plugin/TestDummyProviderFactory.java create mode 100644 src/test/java/org/dependencytrack/resources/v1/PluginResourceTest.java create mode 100644 src/test/resources/META-INF/services/org.dependencytrack.plugin.DummyProviderFactory create mode 100644 src/test/resources/META-INF/services/org.dependencytrack.plugin.Plugin diff --git a/src/main/java/org/dependencytrack/plugin/ConfigRegistry.java b/src/main/java/org/dependencytrack/plugin/ConfigRegistry.java new file mode 100644 index 000000000..a18eccdbe --- /dev/null +++ b/src/main/java/org/dependencytrack/plugin/ConfigRegistry.java @@ -0,0 +1,106 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.plugin; + +import alpine.Config; + +import java.util.Optional; + +import static java.util.Objects.requireNonNull; +import static org.dependencytrack.persistence.jdbi.JdbiFactory.withJdbiHandle; + +/** + * A read-only registry for accessing application configuration. + *

+ * The registry enforces namespacing of property names, + * to prevent {@link Provider}s from accessing values + * belonging to the core application, or other plugins. + *

+ * Namespacing is based on the plugin's, and the provider's name. + * Provider {@code foo} of plugin {@code bar} can access: + *

+ *

+ * Runtime properties are sourced from the {@code CONFIGPROPERTY} database table. + * Deployment properties are sourced from environment variables, and the {@code application.properties} file. + * + * @since 5.6.0 + */ +public class ConfigRegistry { + + private final String pluginName; + private final String providerName; + + public ConfigRegistry(final String pluginName, final String providerName) { + this.pluginName = requireNonNull(pluginName); + this.providerName = requireNonNull(providerName); + } + + /** + * @param propertyName Name of the runtime property. + * @return An {@link Optional} holding the property value, or {@link Optional#empty()}. + */ + public Optional getRuntimeProperty(final String propertyName) { + final String namespacedPropertyName = "%s.provider.%s.%s".formatted(pluginName, providerName, propertyName); + + return withJdbiHandle(handle -> handle.createQuery(""" + SELECT "PROPERTYVALUE" + FROM "CONFIGPROPERTY" + WHERE "GROUPNAME" = 'plugin' + AND "PROPERTYNAME" = :propertyName + """) + .bind("propertyName", namespacedPropertyName) + .mapTo(String.class) + .findOne()); + } + + /** + * @param propertyName Name of the deployment property. + * @return An {@link Optional} holding the property value, or {@link Optional#empty()}. + */ + public Optional getDeploymentProperty(final String propertyName) { + final var key = new DeploymentConfigKey(pluginName, providerName, propertyName); + return Optional.ofNullable(Config.getInstance().getProperty(key)); + } + + record DeploymentConfigKey(String pluginName, String providerName, String name) implements Config.Key { + + DeploymentConfigKey(final String pluginName, final String name) { + this(pluginName, null, name); + } + + @Override + public String getPropertyName() { + if (providerName == null) { + return "%s.%s".formatted(pluginName, name); + } + + return "%s.provider.%s.%s".formatted(pluginName, providerName, name); + } + + @Override + public Object getDefaultValue() { + return null; + } + + } + +} diff --git a/src/main/java/org/dependencytrack/plugin/Plugin.java b/src/main/java/org/dependencytrack/plugin/Plugin.java new file mode 100644 index 000000000..311e6600b --- /dev/null +++ b/src/main/java/org/dependencytrack/plugin/Plugin.java @@ -0,0 +1,46 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.plugin; + +/** + * @since 5.6.0 + */ +public interface Plugin { + + /** + * @return The name of the plugin. Can contain lowercase letters, numbers, and periods. + */ + String name(); + + /** + * @return Whether this plugin is required. Required plugins must have at least one active {@link Provider}. + */ + boolean required(); + + /** + * @return Class of the {@link ProviderFactory} + */ + Class> providerFactoryClass(); + + /** + * @return Class of the {@link Provider} + */ + Class providerClass(); + +} diff --git a/src/main/java/org/dependencytrack/plugin/PluginInitializer.java b/src/main/java/org/dependencytrack/plugin/PluginInitializer.java new file mode 100644 index 000000000..e9ca7f08b --- /dev/null +++ b/src/main/java/org/dependencytrack/plugin/PluginInitializer.java @@ -0,0 +1,47 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.plugin; + +import alpine.common.logging.Logger; + +import javax.servlet.ServletContextEvent; +import javax.servlet.ServletContextListener; + +/** + * @since 5.6.0 + */ +public class PluginInitializer implements ServletContextListener { + + private static final Logger LOGGER = Logger.getLogger(PluginInitializer.class); + + private final PluginManager pluginManager = PluginManager.getInstance(); + + @Override + public void contextInitialized(final ServletContextEvent event) { + LOGGER.info("Loading plugins"); + pluginManager.loadPlugins(); + } + + @Override + public void contextDestroyed(final ServletContextEvent event) { + LOGGER.info("Unloading plugins"); + pluginManager.unloadPlugins(); + } + +} diff --git a/src/main/java/org/dependencytrack/plugin/PluginManager.java b/src/main/java/org/dependencytrack/plugin/PluginManager.java new file mode 100644 index 000000000..6e3df3684 --- /dev/null +++ b/src/main/java/org/dependencytrack/plugin/PluginManager.java @@ -0,0 +1,286 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.plugin; + +import alpine.Config; +import alpine.common.logging.Logger; +import org.dependencytrack.plugin.ConfigRegistry.DeploymentConfigKey; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.ServiceLoader; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.concurrent.locks.ReentrantLock; +import java.util.regex.Pattern; + +import static org.dependencytrack.plugin.ProviderFactory.PRIORITY_HIGHEST; +import static org.dependencytrack.plugin.ProviderFactory.PRIORITY_LOWEST; + +/** + * @since 5.6.0 + */ +public class PluginManager { + + private record ProviderIdentity(Class clazz, String name) { + } + + private static final Logger LOGGER = Logger.getLogger(PluginManager.class); + private static final Pattern PLUGIN_NAME_PATTERN = Pattern.compile("^[a-z0-9.]+$"); + private static final Pattern PROVIDER_NAME_PATTERN = PLUGIN_NAME_PATTERN; + private static final String PROPERTY_PROVIDER_ENABLED = "enabled"; + private static final String PROPERTY_DEFAULT_PROVIDER = "default.provider"; + private static final PluginManager INSTANCE = new PluginManager(); + + private final List loadedPlugins; + private final Map, Plugin> pluginByProviderClass; + private final Map, Set> providerNamesByProviderClass; + private final Map> factoryByProviderKey; + private final Map, ProviderFactory> defaultFactoryByProviderClass; + private final Comparator> providerFactoryComparator; + private final ReentrantLock lock; + + private PluginManager() { + this.loadedPlugins = new ArrayList<>(); + this.pluginByProviderClass = new HashMap<>(); + this.providerNamesByProviderClass = new HashMap<>(); + this.factoryByProviderKey = new HashMap<>(); + this.defaultFactoryByProviderClass = new HashMap<>(); + this.providerFactoryComparator = Comparator + .>comparingInt(ProviderFactory::priority) + .thenComparing(ProviderFactory::providerName); + this.lock = new ReentrantLock(); + } + + public static PluginManager getInstance() { + return INSTANCE; + } + + public List getLoadedPlugins() { + return List.copyOf(loadedPlugins); + } + + @SuppressWarnings("unchecked") + public > U getFactory(final Class providerClass) { + final ProviderFactory factory = defaultFactoryByProviderClass.get(providerClass); + if (factory == null) { + return null; + } + + return (U) factory; + } + + @SuppressWarnings("unchecked") + public > SortedSet getFactories(final Class providerClass) { + final Set providerNames = providerNamesByProviderClass.get(providerClass); + if (providerNames == null) { + return Collections.emptySortedSet(); + } + + final var factories = new TreeSet(providerFactoryComparator); + for (final String providerName : providerNames) { + final var providerKey = new ProviderIdentity(providerClass, providerName); + final ProviderFactory factory = factoryByProviderKey.get(providerKey); + if (factory != null) { + factories.add((U) factory); + } + } + + return factories; + } + + void loadPlugins() { + lock.lock(); + try { + if (!loadedPlugins.isEmpty()) { + // NB: This is primarily to prevent erroneous redundant calls to loadPlugins. + // Under normal circumstances, this method will be called once on application + // startup, making this very unlikely to happen. + throw new IllegalStateException("Plugins were already loaded; Unload them first"); + } + + loadPluginsLocked(); + } finally { + lock.unlock(); + } + } + + private void loadPluginsLocked() { + assert lock.isHeldByCurrentThread() : "Lock is not held by current thread"; + + LOGGER.debug("Discovering plugins"); + final var pluginServiceLoader = ServiceLoader.load(Plugin.class); + for (final Plugin plugin : pluginServiceLoader) { + if (!PLUGIN_NAME_PATTERN.matcher(plugin.name()).matches()) { + throw new IllegalStateException("%s is not a valid plugin name".formatted(plugin.name())); + } + + loadProvidersForPlugin(plugin); + + LOGGER.debug("Loaded plugin %s".formatted(plugin.name())); + loadedPlugins.add(plugin); + } + + determineDefaultProviders(); + + assertRequiredPlugins(); + } + + private void loadProvidersForPlugin(final Plugin plugin) { + LOGGER.debug("Discovering providers for plugin %s".formatted(plugin.name())); + final ServiceLoader> providerFactoryServiceLoader = ServiceLoader.load(plugin.providerFactoryClass()); + for (final ProviderFactory providerFactory : providerFactoryServiceLoader) { + if (!PROVIDER_NAME_PATTERN.matcher(providerFactory.providerName()).matches()) { + throw new IllegalStateException("%s is not a valid provider name".formatted(providerFactory.providerName())); + } + + LOGGER.debug("Discovered provider %s for plugin %s".formatted(providerFactory.providerName(), plugin.name())); + final var configRegistry = new ConfigRegistry(plugin.name(), providerFactory.providerName()); + final boolean isEnabled = configRegistry.getDeploymentProperty(PROPERTY_PROVIDER_ENABLED).map(Boolean::parseBoolean).orElse(true); + if (!isEnabled) { + LOGGER.debug("Provider %s for plugin %s is disabled; Skipping".formatted(providerFactory.providerName(), plugin.name())); + continue; + } + + if (providerFactory.priority() < PRIORITY_HIGHEST) { + throw new IllegalStateException(""" + Provider %s has an invalid priority of %d; \ + Allowed range is [%d..%d] (highest to lowest priority)\ + """.formatted(providerFactory.providerName(), providerFactory.priority(), PRIORITY_HIGHEST, PRIORITY_LOWEST) + ); + } + + LOGGER.debug("Initializing provider %s for plugin %s".formatted(providerFactory.providerName(), plugin.name())); + try { + providerFactory.init(configRegistry); + } catch (RuntimeException e) { + LOGGER.warn("Failed to initialize provider %s for plugin %s; Skipping".formatted(providerFactory.providerName(), plugin.name()), e); + continue; + } + + pluginByProviderClass.put(plugin.providerClass(), plugin); + + providerNamesByProviderClass.compute(plugin.providerClass(), (ignored, providerNames) -> { + if (providerNames == null) { + return new HashSet<>(Set.of(providerFactory.providerName())); + } + + providerNames.add(providerFactory.providerName()); + return providerNames; + }); + + final var providerIdentity = new ProviderIdentity(plugin.providerClass(), providerFactory.providerName()); + factoryByProviderKey.put(providerIdentity, providerFactory); + } + } + + private void determineDefaultProviders() { + for (final Class providerClass : providerNamesByProviderClass.keySet()) { + final SortedSet> factories = getFactories(providerClass); + if (factories == null || factories.isEmpty()) { + LOGGER.debug("No factories available for provider class %s; Skipping".formatted(providerClass.getName())); + continue; + } + + final Plugin plugin = pluginByProviderClass.get(providerClass); + if (plugin == null) { + throw new IllegalStateException(""" + No plugin exists for provider class %s; \ + This is likely a logic error in the plugin loading procedure\ + """.formatted(providerClass)); + } + + final ProviderFactory providerFactory; + final var defaultProviderConfigKey = new DeploymentConfigKey(plugin.name(), PROPERTY_DEFAULT_PROVIDER); + final String providerName = Config.getInstance().getProperty(defaultProviderConfigKey); + if (providerName == null) { + LOGGER.debug(""" + No default provider configured for plugin %s; \ + Choosing based on priority""".formatted(plugin.name())); + providerFactory = factories.first(); + LOGGER.debug("Chose provider %s with priority %d for plugin %s" + .formatted(providerFactory.providerName(), providerFactory.priority(), plugin.name())); + } else { + LOGGER.debug("Using configured default provider %s for plugin %s".formatted(providerName, plugin.name())); + providerFactory = factories.stream() + .filter(factory -> factory.providerName().equals(providerName)) + .findFirst() + .orElseThrow(() -> new NoSuchElementException(""" + No provider named %s exists for plugin %s\ + """.formatted(providerName, plugin.name()))); + } + + defaultFactoryByProviderClass.put(providerClass, providerFactory); + } + } + + private void assertRequiredPlugins() { + for (final Plugin plugin : loadedPlugins) { + if (!plugin.required()) { + continue; + } + + if (getFactory(plugin.providerClass()) == null) { + throw new IllegalStateException("Plugin %s is required, but no provider is active".formatted(plugin.name())); + } + } + } + + void unloadPlugins() { + lock.lock(); + try { + unloadPluginsLocked(); + defaultFactoryByProviderClass.clear(); + factoryByProviderKey.clear(); + providerNamesByProviderClass.clear(); + pluginByProviderClass.clear(); + loadedPlugins.clear(); + } finally { + lock.unlock(); + } + } + + private void unloadPluginsLocked() { + assert lock.isHeldByCurrentThread() : "Lock is not held by current thread"; + + for (final Plugin plugin : loadedPlugins) { + LOGGER.debug("Closing providers for plugin %s".formatted(plugin.name())); + + for (ProviderFactory providerFactory : getFactories(plugin.providerClass())) { + LOGGER.debug("Closing provider %s for plugin %s".formatted(providerFactory.providerName(), plugin.name())); + + try { + providerFactory.close(); + } catch (RuntimeException e) { + LOGGER.warn("Failed to close provider %s for plugin %s".formatted(providerFactory.providerName(), plugin.name()), e); + } + } + + LOGGER.debug("Unloaded plugin %s".formatted(plugin.name())); + } + } + +} diff --git a/src/main/java/org/dependencytrack/plugin/Provider.java b/src/main/java/org/dependencytrack/plugin/Provider.java new file mode 100644 index 000000000..c7f144f16 --- /dev/null +++ b/src/main/java/org/dependencytrack/plugin/Provider.java @@ -0,0 +1,34 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.plugin; + +/** + * @since 5.6.0 + */ +public interface Provider extends AutoCloseable { + + /** + * {@inheritDoc} + */ + @Override + default void close() { + // Default no-op to remove checked exception from method signature. + } + +} diff --git a/src/main/java/org/dependencytrack/plugin/ProviderFactory.java b/src/main/java/org/dependencytrack/plugin/ProviderFactory.java new file mode 100644 index 000000000..74eee2d07 --- /dev/null +++ b/src/main/java/org/dependencytrack/plugin/ProviderFactory.java @@ -0,0 +1,60 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.plugin; + +/** + * @since 5.6.0 + */ +public interface ProviderFactory extends AutoCloseable { + + int PRIORITY_HIGHEST = 0; + int PRIORITY_LOWEST = Integer.MAX_VALUE; + + /** + * @return Name of the provider. Can contain lowercase letters, numbers, and periods. + */ + String providerName(); + + /** + * @return Priority of the provider. Must be a value between {@value #PRIORITY_HIGHEST} + * (highest priority) and {@value #PRIORITY_LOWEST} (lowest priority). + */ + int priority(); + + /** + * Initialize the factory. This method is called once during application startup. + * + * @param configRegistry A {@link ConfigRegistry} to read configuration from. + */ + void init(final ConfigRegistry configRegistry); + + /** + * @return An instance of {@link T}. + */ + T create(); + + /** + * {@inheritDoc} + */ + @Override + default void close() { + // Default no-op to remove checked exception from method signature. + } + +} diff --git a/src/main/java/org/dependencytrack/resources/v1/PluginResource.java b/src/main/java/org/dependencytrack/resources/v1/PluginResource.java new file mode 100644 index 000000000..02c5e380b --- /dev/null +++ b/src/main/java/org/dependencytrack/resources/v1/PluginResource.java @@ -0,0 +1,79 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.resources.v1; + +import alpine.server.auth.PermissionRequired; +import alpine.server.resources.AlpineResource; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiResponse; +import io.swagger.annotations.ApiResponses; +import io.swagger.annotations.Authorization; +import org.dependencytrack.auth.Permissions; +import org.dependencytrack.plugin.PluginManager; +import org.dependencytrack.plugin.Provider; +import org.dependencytrack.plugin.ProviderFactory; +import org.dependencytrack.resources.v1.vo.LoadedPluginListResponseItem; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.util.List; +import java.util.SortedSet; + +@Path("/v1/plugin") +@Api(value = "plugin", authorizations = @Authorization(value = "X-Api-Key")) +public class PluginResource extends AlpineResource { + + @GET + @Produces(MediaType.APPLICATION_JSON) + @ApiOperation( + value = "Returns a list of all loaded plugins", + response = LoadedPluginListResponseItem.class, + responseContainer = "List", + notes = "

Requires permission SYSTEM_CONFIGURATION

" + ) + @ApiResponses(value = { + @ApiResponse(code = 401, message = "Unauthorized") + }) + @PermissionRequired(Permissions.Constants.SYSTEM_CONFIGURATION) + public Response getAllLoadedPlugins() { + final var providerManager = PluginManager.getInstance(); + + final List loadedPlugins = providerManager.getLoadedPlugins().stream() + .map(plugin -> { + final SortedSet> factories = + providerManager.getFactories(plugin.providerClass()); + final List providerNames = factories.stream() + .map(ProviderFactory::providerName) + .toList(); + + final ProviderFactory defaultFactory = providerManager.getFactory(plugin.providerClass()); + final String defaultProviderName = defaultFactory != null ? defaultFactory.providerName() : null; + + return new LoadedPluginListResponseItem(plugin.name(), providerNames, defaultProviderName); + }) + .toList(); + + return Response.ok(loadedPlugins).build(); + } + +} diff --git a/src/main/java/org/dependencytrack/resources/v1/vo/LoadedPluginListResponseItem.java b/src/main/java/org/dependencytrack/resources/v1/vo/LoadedPluginListResponseItem.java new file mode 100644 index 000000000..1288a2f8e --- /dev/null +++ b/src/main/java/org/dependencytrack/resources/v1/vo/LoadedPluginListResponseItem.java @@ -0,0 +1,35 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.resources.v1.vo; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.annotations.ApiModelProperty; + +import java.util.List; + +/** + * @since 5.6.0 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record LoadedPluginListResponseItem( + @ApiModelProperty(value = "Name of the plugin", required = true) String name, + @ApiModelProperty(value = "Names of all loaded providers for the plugin") List providers, + @ApiModelProperty(value = "Name of the default provider for the plugin") String defaultProvider +) { +} diff --git a/src/main/resources/META-INF/services/org.dependencytrack.plugin.Plugin b/src/main/resources/META-INF/services/org.dependencytrack.plugin.Plugin new file mode 100644 index 000000000..e69de29bb diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index 392ed5cb7..744bfd2db 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -28,15 +28,15 @@ alpine.server.metrics.MetricsInitializer - - alpine.server.persistence.PersistenceInitializer - org.dependencytrack.persistence.migration.MigrationInitializer alpine.server.persistence.PersistenceManagerFactory + + org.dependencytrack.plugin.PluginInitializer + org.dependencytrack.health.HealthCheckInitializer diff --git a/src/test/java/org/dependencytrack/PersistenceCapableTest.java b/src/test/java/org/dependencytrack/PersistenceCapableTest.java index 648649c94..66b73b419 100644 --- a/src/test/java/org/dependencytrack/PersistenceCapableTest.java +++ b/src/test/java/org/dependencytrack/PersistenceCapableTest.java @@ -25,6 +25,7 @@ import org.datanucleus.api.jdo.JDOPersistenceManagerFactory; import org.dependencytrack.event.kafka.KafkaProducerInitializer; import org.dependencytrack.persistence.QueryManager; +import org.dependencytrack.plugin.PluginManagerTestUtil; import org.junit.After; import org.junit.AfterClass; import org.junit.Before; @@ -53,6 +54,8 @@ public static void init() { postgresContainer = new PostgresTestContainer(); postgresContainer.start(); + + PluginManagerTestUtil.loadPlugins(); } @Before @@ -83,6 +86,8 @@ public void after() { @AfterClass public static void tearDownClass() { + PluginManagerTestUtil.unloadPlugins(); + if (postgresContainer != null) { postgresContainer.stopWhenNotReusing(); } diff --git a/src/test/java/org/dependencytrack/ResourceTest.java b/src/test/java/org/dependencytrack/ResourceTest.java index 2920907b7..ea6a90c74 100644 --- a/src/test/java/org/dependencytrack/ResourceTest.java +++ b/src/test/java/org/dependencytrack/ResourceTest.java @@ -27,6 +27,7 @@ import org.dependencytrack.auth.Permissions; import org.dependencytrack.event.kafka.KafkaProducerInitializer; import org.dependencytrack.persistence.QueryManager; +import org.dependencytrack.plugin.PluginManagerTestUtil; import org.junit.After; import org.junit.AfterClass; import org.junit.Before; @@ -63,6 +64,7 @@ public abstract class ResourceTest { protected final String V1_NOTIFICATION_RULE = "/v1/notification/rule"; protected final String V1_OIDC = "/v1/oidc"; protected final String V1_PERMISSION = "/v1/permission"; + protected final String V1_PLUGIN = "/v1/plugin"; protected final String V1_OSV_ECOSYSTEM = "/v1/integration/osv/ecosystem"; protected final String V1_POLICY = "/v1/policy"; protected final String V1_POLICY_VIOLATION = "/v1/violation"; @@ -105,6 +107,8 @@ public static void init() { postgresContainer = new PostgresTestContainer(); postgresContainer.start(); + + PluginManagerTestUtil.loadPlugins(); } @Before @@ -136,6 +140,8 @@ public void after() { @AfterClass public static void tearDownClass() { + PluginManagerTestUtil.unloadPlugins(); + if (postgresContainer != null) { postgresContainer.stopWhenNotReusing(); } diff --git a/src/test/java/org/dependencytrack/plugin/ConfigRegistryTest.java b/src/test/java/org/dependencytrack/plugin/ConfigRegistryTest.java new file mode 100644 index 000000000..37d3d86a4 --- /dev/null +++ b/src/test/java/org/dependencytrack/plugin/ConfigRegistryTest.java @@ -0,0 +1,73 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.plugin; + +import alpine.model.IConfigProperty.PropertyType; +import org.dependencytrack.PersistenceCapableTest; +import org.junit.Rule; +import org.junit.Test; +import org.junit.contrib.java.lang.system.EnvironmentVariables; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ConfigRegistryTest extends PersistenceCapableTest { + + @Rule + public EnvironmentVariables environmentVariables = new EnvironmentVariables(); + + @Test + public void testGetRuntimeProperty() { + qm.createConfigProperty( + /* groupName */ "plugin", + /* propertyName */ "foo.provider.bar.baz", + /* propertyValue */ "qux", + PropertyType.STRING, + /* description */ null + ); + + final var configRegistry = new ConfigRegistry("foo", "bar"); + final Optional optionalProperty = configRegistry.getRuntimeProperty("baz"); + assertThat(optionalProperty).contains("qux"); + } + + @Test + public void testGetRuntimePropertyThatDoesNotExist() { + final var configRegistry = new ConfigRegistry("foo", "bar"); + final Optional optionalProperty = configRegistry.getRuntimeProperty("baz"); + assertThat(optionalProperty).isNotPresent(); + } + + @Test + public void testDeploymentProperty() { + environmentVariables.set("FOO_PROVIDER_BAR_BAZ", "qux"); + final var configRegistry = new ConfigRegistry("foo", "bar"); + final Optional optionalProperty = configRegistry.getDeploymentProperty("baz"); + assertThat(optionalProperty).contains("qux"); + } + + @Test + public void testDeploymentPropertyThatDoesNotExist() { + final var configRegistry = new ConfigRegistry("foo", "bar"); + final Optional optionalProperty = configRegistry.getDeploymentProperty("baz"); + assertThat(optionalProperty).isNotPresent(); + } + +} \ No newline at end of file diff --git a/src/test/java/org/dependencytrack/plugin/DummyPlugin.java b/src/test/java/org/dependencytrack/plugin/DummyPlugin.java new file mode 100644 index 000000000..9e2fc9fb1 --- /dev/null +++ b/src/test/java/org/dependencytrack/plugin/DummyPlugin.java @@ -0,0 +1,43 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.plugin; + +public class DummyPlugin implements Plugin { + + @Override + public String name() { + return "dummy"; + } + + @Override + public boolean required() { + return false; + } + + @Override + public Class> providerFactoryClass() { + return DummyProviderFactory.class; + } + + @Override + public Class providerClass() { + return DummyProvider.class; + } + +} diff --git a/src/test/java/org/dependencytrack/plugin/DummyProvider.java b/src/test/java/org/dependencytrack/plugin/DummyProvider.java new file mode 100644 index 000000000..dea4a3cea --- /dev/null +++ b/src/test/java/org/dependencytrack/plugin/DummyProvider.java @@ -0,0 +1,22 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.plugin; + +public interface DummyProvider extends Provider { +} diff --git a/src/test/java/org/dependencytrack/plugin/DummyProviderFactory.java b/src/test/java/org/dependencytrack/plugin/DummyProviderFactory.java new file mode 100644 index 000000000..99752b61c --- /dev/null +++ b/src/test/java/org/dependencytrack/plugin/DummyProviderFactory.java @@ -0,0 +1,22 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.plugin; + +public interface DummyProviderFactory extends ProviderFactory { +} diff --git a/src/test/java/org/dependencytrack/plugin/PluginManagerTest.java b/src/test/java/org/dependencytrack/plugin/PluginManagerTest.java new file mode 100644 index 000000000..c29a7f959 --- /dev/null +++ b/src/test/java/org/dependencytrack/plugin/PluginManagerTest.java @@ -0,0 +1,77 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.plugin; + +import org.dependencytrack.PersistenceCapableTest; +import org.junit.Test; + +import java.util.List; +import java.util.SortedSet; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +public class PluginManagerTest extends PersistenceCapableTest { + + interface InvalidProvider extends Provider { + } + + @Test + public void testGetLoadedPlugins() { + final List loadedPlugins = PluginManager.getInstance().getLoadedPlugins(); + assertThat(loadedPlugins).hasSize(1); + assertThat(loadedPlugins).isUnmodifiable(); + } + + @Test + public void testGetFactory() { + final ProviderFactory factory = + PluginManager.getInstance().getFactory(DummyProvider.class); + assertThat(factory).isNotNull(); + } + + @Test + public void testGetFactoryForInvalidProvider() { + final ProviderFactory factory = + PluginManager.getInstance().getFactory(InvalidProvider.class); + assertThat(factory).isNull(); + } + + @Test + public void testGetFactories() { + final SortedSet> factories = + PluginManager.getInstance().getFactories(DummyProvider.class); + assertThat(factories).hasSize(1); + } + + @Test + public void testGetFactoriesForInvalidProvider() { + final SortedSet> factories = + PluginManager.getInstance().getFactories(InvalidProvider.class); + assertThat(factories).isEmpty(); + } + + @Test + public void testLoadPluginsRepeatedly() { + assertThatExceptionOfType(IllegalStateException.class) + .isThrownBy(() -> PluginManager.getInstance().loadPlugins()) + .withMessage("Plugins were already loaded; Unload them first"); + } + +} \ No newline at end of file diff --git a/src/test/java/org/dependencytrack/plugin/PluginManagerTestUtil.java b/src/test/java/org/dependencytrack/plugin/PluginManagerTestUtil.java new file mode 100644 index 000000000..02cf9a1fc --- /dev/null +++ b/src/test/java/org/dependencytrack/plugin/PluginManagerTestUtil.java @@ -0,0 +1,31 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.plugin; + +public class PluginManagerTestUtil { + + public static void loadPlugins() { + PluginManager.getInstance().loadPlugins(); + } + + public static void unloadPlugins() { + PluginManager.getInstance().unloadPlugins(); + } + +} diff --git a/src/test/java/org/dependencytrack/plugin/TestDummyProviderFactory.java b/src/test/java/org/dependencytrack/plugin/TestDummyProviderFactory.java new file mode 100644 index 000000000..47bec226c --- /dev/null +++ b/src/test/java/org/dependencytrack/plugin/TestDummyProviderFactory.java @@ -0,0 +1,42 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.plugin; + +public class TestDummyProviderFactory implements DummyProviderFactory { + + @Override + public String providerName() { + return "test"; + } + + @Override + public int priority() { + return 0; + } + + @Override + public void init(final ConfigRegistry configRegistry) { + } + + @Override + public DummyProvider create() { + return null; + } + +} diff --git a/src/test/java/org/dependencytrack/resources/v1/PluginResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/PluginResourceTest.java new file mode 100644 index 000000000..0277a1127 --- /dev/null +++ b/src/test/java/org/dependencytrack/resources/v1/PluginResourceTest.java @@ -0,0 +1,61 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.resources.v1; + +import alpine.server.filters.ApiFilter; +import alpine.server.filters.AuthenticationFilter; +import org.dependencytrack.JerseyTestRule; +import org.dependencytrack.ResourceTest; +import org.glassfish.jersey.server.ResourceConfig; +import org.junit.ClassRule; +import org.junit.Test; + +import javax.ws.rs.core.Response; + +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; + +public class PluginResourceTest extends ResourceTest { + + @ClassRule + public static JerseyTestRule jersey = new JerseyTestRule( + new ResourceConfig(PluginResource.class) + .register(ApiFilter.class) + .register(AuthenticationFilter.class)); + + @Test + public void getAllLoadedPluginsTest() { + final Response response = jersey.target(V1_PLUGIN).request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "defaultProvider": "test", + "name": "dummy", + "providers": [ + "test" + ] + } + ] + """); + } + +} \ No newline at end of file diff --git a/src/test/resources/META-INF/services/org.dependencytrack.plugin.DummyProviderFactory b/src/test/resources/META-INF/services/org.dependencytrack.plugin.DummyProviderFactory new file mode 100644 index 000000000..2d760f271 --- /dev/null +++ b/src/test/resources/META-INF/services/org.dependencytrack.plugin.DummyProviderFactory @@ -0,0 +1 @@ +org.dependencytrack.plugin.TestDummyProviderFactory \ No newline at end of file diff --git a/src/test/resources/META-INF/services/org.dependencytrack.plugin.Plugin b/src/test/resources/META-INF/services/org.dependencytrack.plugin.Plugin new file mode 100644 index 000000000..e44ebf020 --- /dev/null +++ b/src/test/resources/META-INF/services/org.dependencytrack.plugin.Plugin @@ -0,0 +1 @@ +org.dependencytrack.plugin.DummyPlugin \ No newline at end of file