Skip to content

Lowcoder Plugin System #767

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Mar 24, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
new: plugin endpoint security basics
  • Loading branch information
ludomikula committed Mar 5, 2024
commit b58a7429e8f1473a34034867ff07b54bc1343d1c
65 changes: 3 additions & 62 deletions server/api-service/PLUGIN.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Lowcoder plugin system (WIP)
# Lowcoder backend plugin system

This is an ongoing effort to refactor current plugin system based on pf4j library.

Expand Down Expand Up @@ -50,73 +50,14 @@ Plugin jar can be structured in any way you like. It can be a plain java project

It is composed from several parts:
- class(es) implementing **LowcoderPlugin** interface
- class(es) implementing **LowcoderEndpoint** interface, containing endpoint handler functions marked with **@EndpointExtension** annotation. These functions must obey following format:
- class(es) implementing **PluginEndpoint** interface, containing endpoint handler functions marked with **@EndpointExtension** annotation. These functions must obey following format:

```java
@EndpointExtension(uri = <endpoint uri>, method = <HTTP method>)
public Mono<ServerResponse> <handler name>(ServerRequest request)
public EndpointResponse <handler name>(EndpointRequest request)
{
... your endpoint logic implementation
}

for example:

@EndpointExtension(uri = "/hello-world", method = Method.GET)
public Mono<ServerResponse> helloWorld(ServerRequest request)
{
return ServerResponse.ok().body(Mono.just(Hello.builder().message("Hello world!").build()), Hello.class);
}
```
- TODO: class(es) impelemting **LowcoderDatasource** interface

### LowcoderPlugin implementations

Methods of interest:
- **pluginId()** - unique plugin ID - if a plugin with such ID is already loaded, subsequent plugins whith this ID will be ignored
- **description()** - short plugin description
- **load(ApplicationContext parentContext)** - is called during plugin startup - this is the place where you should completely initialize your plugin. If initialization fails, return false
- **unload()** - is called during lowcoder API server shutdown - this is the place where you should release all resources
- **endpoints()** - needs to contain all initialized **PluginEndpoints** you want to expose, for example:

```java
@Override
public List<PluginEndpoint> endpoints()
{
List<PluginEndpoint> endpoints = new ArrayList<>();

endpoints.add(new HelloWorldEndpoint());

return endpoints;
}
```
- **pluginInfo()** - should return a record object with additional information about your plugin. It is serialized to JSON as part of the **/plugins** listing (see **"info"** object in this example):

```json
[
{
"id": "example-plugin",
"description": "Example plugin for lowcoder platform",
"info": {}
},
{
"id": "enterprise",
"description": "Lowcoder enterprise plugin",
"info": {
"enabledFeatures": [
"endpointApiUsage"
]
}
}
]
```

## TODOs

1. Implement endpoint security - currently all plugin endpoints are public (probably by adding **security** attribute to **@EndpointExtension** and enforcing it)


## QUESTIONS / CONSIDERATIONS

1. currently the plugin endpoints are prefixed with **/plugin/{pluginId}/** - this is hardcoded, do we want to make it configurable?


19 changes: 11 additions & 8 deletions server/api-service/lowcoder-sdk/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,6 @@

<name>lowcoder-sdk</name>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>

<java.version>17</java.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
Expand Down Expand Up @@ -173,7 +166,17 @@
<artifactId>validation-api</artifactId>
</dependency>
</dependencies>


<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>

<java.version>17</java.version>

<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>

<dependencyManagement>
<dependencies>
<dependency>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
import org.lowcoder.domain.application.model.ApplicationStatus;
import org.lowcoder.domain.application.model.ApplicationType;
import org.lowcoder.domain.permission.model.ResourceRole;
import org.lowcoder.infra.event.EventType;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
Expand Down Expand Up @@ -106,15 +105,15 @@ public Mono<ResponseView<ApplicationView>> getPublishedApplication(@PathVariable
public Mono<ResponseView<ApplicationView>> getPublishedMarketPlaceApplication(@PathVariable String applicationId) {
return applicationApiService.getPublishedApplication(applicationId, ApplicationRequestType.PUBLIC_TO_MARKETPLACE)
.delayUntil(applicationView -> applicationApiService.updateUserApplicationLastViewTime(applicationId))
.delayUntil(applicationView -> businessEventPublisher.publishApplicationCommonEvent(applicationView, EventType.VIEW))
.delayUntil(applicationView -> businessEventPublisher.publishApplicationCommonEvent(applicationView, APPLICATION_VIEW))
.map(ResponseView::success);
}

@Override
public Mono<ResponseView<ApplicationView>> getAgencyProfileApplication(@PathVariable String applicationId) {
return applicationApiService.getPublishedApplication(applicationId, ApplicationRequestType.AGENCY_PROFILE)
.delayUntil(applicationView -> applicationApiService.updateUserApplicationLastViewTime(applicationId))
.delayUntil(applicationView -> businessEventPublisher.publishApplicationCommonEvent(applicationView, EventType.VIEW))
.delayUntil(applicationView -> businessEventPublisher.publishApplicationCommonEvent(applicationView, APPLICATION_VIEW))
.map(ResponseView::success);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package org.lowcoder.api.framework.plugin;

import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
Expand All @@ -20,6 +19,11 @@ public class PluginClassLoader extends URLClassLoader
private static final ClassLoader baseClassLoader = ClassLoader.getPlatformClassLoader();
private final ClassLoader appClassLoader = Thread.currentThread().getContextClassLoader();

private static final String[] excludedPaths = new String[] {
"org.lowcoder.plugin.api.",
"org/lowcoder/plugin/api/"
};

public PluginClassLoader(String name, Path pluginPath)
{
super(name, pathToURLs(pluginPath), baseClassLoader);
Expand All @@ -34,7 +38,7 @@ protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundE
return clazz;
}

if (name.startsWith("org.lowcoder.plugin.api."))
if (StringUtils.startsWithAny(name, excludedPaths))
{
try
{
Expand Down Expand Up @@ -67,7 +71,7 @@ protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundE
@Override
public URL getResource(String name) {
Objects.requireNonNull(name);
if (StringUtils.startsWithAny(name, "org/lowcoder/plugin/api/", "org.lowcoder.plugin.api."))
if (StringUtils.startsWithAny(name, excludedPaths))
{
return appClassLoader.getResource(name);
}
Expand All @@ -79,7 +83,7 @@ public URL getResource(String name) {
public Enumeration<URL> getResources(String name) throws IOException
{
Objects.requireNonNull(name);
if (StringUtils.startsWithAny(name, "org/lowcoder/plugin/api/", "org.lowcoder.plugin.api."))
if (StringUtils.startsWithAny(name, excludedPaths))
{
return appClassLoader.getResources(name);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,23 @@
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.lowcoder.api.framework.plugin.data.PluginServerRequest;
import org.lowcoder.api.framework.plugin.security.SecuredEndpoint;
import org.lowcoder.plugin.api.EndpointExtension;
import org.lowcoder.plugin.api.PluginEndpoint;
import org.lowcoder.plugin.api.data.EndpointRequest;
import org.lowcoder.plugin.api.data.EndpointResponse;
import org.lowcoder.sdk.exception.BaseException;
import org.springframework.aop.TargetSource;
import org.springframework.aop.framework.ProxyFactoryBean;
import org.springframework.aop.target.SimpleBeanTargetSource;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.core.ResolvableType;
import org.springframework.http.ResponseCookie;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.RequestPredicate;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
Expand Down Expand Up @@ -80,48 +84,47 @@ public List<RouterFunction<ServerResponse>> registeredEndpoints()

private void registerEndpointHandler(String urlPrefix, PluginEndpoint endpoint, Method handler)
{
if (handler.isAnnotationPresent(EndpointExtension.class))
if (!handler.isAnnotationPresent(EndpointExtension.class) || !checkHandlerMethod(handler))
{
if (checkHandlerMethod(handler))
if (handler.isAnnotationPresent(EndpointExtension.class))
{

EndpointExtension endpointMeta = handler.getAnnotation(EndpointExtension.class);
String endpointName = endpoint.getClass().getSimpleName() + "_" + handler.getName();

RouterFunction<ServerResponse> routerFunction = route(createRequestPredicate(urlPrefix, endpointMeta), req ->
{
Mono<ServerResponse> result = null;
try
{
EndpointResponse response = (EndpointResponse)handler.invoke(endpoint, PluginServerRequest.fromServerRequest(req));
result = createServerResponse(response);
}
catch (IllegalAccessException | InvocationTargetException cause)
{
throw new BaseException("Error running handler for [ " + endpointMeta.method() + ": " + endpointMeta.uri() + "] !");
}
return result;
});
routes.add(routerFunction);
registerRouterFunctionMapping(endpointName, routerFunction);

log.info("Registered endpoint: {} -> {}: {}", endpoint.getClass().getSimpleName(), endpointMeta.method(), urlPrefix + endpointMeta.uri());
}
else
{
log.error("Cannot register plugin endpoint: {} -> {}! Handler method must be defined as: public Mono<ServerResponse> {}(ServerRequest request)", endpoint.getClass().getSimpleName(), handler.getName(), handler.getName());
log.debug("Not registering plugin endpoint method: {} -> {}! Handler method must be defined as: public EndpointResponse methodName(EndpointRequest request)", endpoint.getClass().getSimpleName(), handler.getName(), handler.getName());
}
return;
}

EndpointExtension endpointMeta = handler.getAnnotation(EndpointExtension.class);
String endpointName = endpoint.getClass().getSimpleName() + "_" + handler.getName();
RouterFunction<ServerResponse> routerFunction = route(createRequestPredicate(urlPrefix, endpointMeta), req -> runPluginEndpointMethod(endpoint, endpointMeta, handler, req));
routes.add(routerFunction);
registerRouterFunctionMapping(endpointName, routerFunction);

log.info("Registered endpoint: {} -> {}: {}", endpoint.getClass().getSimpleName(), endpointMeta.method(), urlPrefix + endpointMeta.uri());
}

@SecuredEndpoint
public Mono<ServerResponse> runPluginEndpointMethod(PluginEndpoint endpoint, EndpointExtension endpointMeta, Method handler, ServerRequest request)
{
Mono<ServerResponse> result = null;
try
{
log.info("Running plugin endpoint method {}\nRequest: {}", handler.getName(), request);

EndpointResponse response = (EndpointResponse)handler.invoke(endpoint, PluginServerRequest.fromServerRequest(request));
result = createServerResponse(response);
}
catch (IllegalAccessException | InvocationTargetException cause)
{
throw new BaseException("Error running handler for [ " + endpointMeta.method() + ": " + endpointMeta.uri() + "] !");
}
return result;
}


private void registerRouterFunctionMapping(String endpointName, RouterFunction<ServerResponse> routerFunction)
{
String beanName = "pluginEndpoint_" + endpointName + "_" + System.currentTimeMillis();

((GenericApplicationContext)applicationContext).registerBean(beanName, RouterFunction.class, () -> {
return routerFunction;
});

((GenericApplicationContext)applicationContext).registerBean(beanName, RouterFunction.class, () -> routerFunction );
log.debug("Registering RouterFunction bean definition: {}", beanName);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package org.lowcoder.api.framework.plugin.security;

import java.util.function.Supplier;

import org.aopalliance.intercept.MethodInvocation;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.core.Authentication;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class EndpointAuthorizationManager implements AuthorizationManager<MethodInvocation>
{

@Override
public AuthorizationDecision check(Supplier<Authentication> authentication, MethodInvocation invocation)
{
log.info("Checking plugin endpoint invocation security for {}", invocation.getMethod().getName());

return new AuthorizationDecision(true);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import org.aopalliance.intercept.MethodInvocation;
import org.apache.commons.lang3.StringUtils;
import org.lowcoder.plugin.api.EndpointExtension;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.EvaluationException;
import org.springframework.expression.Expression;
Expand All @@ -21,7 +20,7 @@
import reactor.core.publisher.Mono;

@Slf4j
@Component
//@Component
public class PluginAuthorizationManager implements ReactiveAuthorizationManager<MethodInvocation>
{
private final MethodSecurityExpressionHandler expressionHandler;
Expand All @@ -34,10 +33,9 @@ public PluginAuthorizationManager()
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> authentication, MethodInvocation invocation)
{
log.info(" invocation :: {}", invocation.getMethod());
log.info("Checking plugin reactive endpoint invocation security for {}", invocation.getMethod().getName());

Method method = invocation.getMethod();
EndpointExtension endpointExtension = AnnotationUtils.findAnnotation(method, EndpointExtension.class);
EndpointExtension endpointExtension = (EndpointExtension)invocation.getArguments()[1];
if (endpointExtension == null || StringUtils.isBlank(endpointExtension.authorize()))
{
return Mono.empty();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.lowcoder.api.framework.plugin.security;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface SecuredEndpoint {

}
7 changes: 1 addition & 6 deletions server/api-service/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,12 @@


<properties>
<revision>2.3.0-SNAPSHOT</revision>
<revision>2.4.0</revision>
<java.version>17</java.version>
<javadoc.disabled>true</javadoc.disabled>
<deploy.disabled>true</deploy.disabled>
<source.disabled>true</source.disabled>
<project.groupId>org.lowcoder</project.groupId>
<project.version>1.0-SNAPSHOT</project.version>
<skipDockerBuild>true</skipDockerBuild>
<log4j2.version>2.17.0</log4j2.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>

<repositories>
Expand Down