diff --git a/src/main/java/run/halo/app/core/extension/Role.java b/src/main/java/run/halo/app/core/extension/Role.java index 6b72ed0cd..d0340921d 100644 --- a/src/main/java/run/halo/app/core/extension/Role.java +++ b/src/main/java/run/halo/app/core/extension/Role.java @@ -4,7 +4,9 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.ToString; +import org.apache.commons.lang3.StringUtils; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; @@ -32,29 +34,28 @@ public class Role extends AbstractExtension { * @author guqing * @since 2.0.0 */ - @Data + @Getter public static class PolicyRule { - /** * APIGroups is the name of the APIGroup that contains the resources. * If multiple API groups are specified, any action requested against one of the enumerated * resources in any API group will be allowed. */ - String[] apiGroups; + final String[] apiGroups; /** * Resources is a list of resources this rule applies to. '*' represents all resources in * the specified apiGroups. - * '*/foo' represents the subresource 'foo' for all resources in - * the specified apiGroups. + * '*/foo' represents the subresource 'foo' for all resources in the specified + * apiGroups. */ - String[] resources; + final String[] resources; /** * ResourceNames is an optional white list of names that the rule applies to. An empty set * means that everything is allowed. */ - String[] resourceNames; + final String[] resourceNames; /** * NonResourceURLs is a set of partial urls that a user should have access to. @@ -66,19 +67,26 @@ public class Role extends AbstractExtension { * Rules can either apply to API resources (such as "pods" or "secrets") or non-resource * URL paths (such as "/api"), but not both. */ - String[] nonResourceURLs; + final String[] nonResourceURLs; /** * about who the rule applies to or which namespace the rule applies to. */ - String[] verbs; + final String[] verbs; + + /** + * If the plugin name exists, it means that the API is provided by the plugin. + */ + final String pluginName; public PolicyRule() { - this(null, null, null, null, null); + this(null, null, null, null, null, null); } - public PolicyRule(String[] apiGroups, String[] resources, String[] resourceNames, + public PolicyRule(String pluginName, String[] apiGroups, String[] resources, + String[] resourceNames, String[] nonResourceURLs, String[] verbs) { + this.pluginName = StringUtils.defaultString(pluginName); this.apiGroups = nullElseEmpty(apiGroups); this.resources = nullElseEmpty(resources); this.resourceNames = nullElseEmpty(resourceNames); @@ -95,11 +103,22 @@ public class Role extends AbstractExtension { public static class Builder { String[] apiGroups; + String[] resources; + String[] resourceNames; + String[] nonResourceURLs; + String[] verbs; + String pluginName; + + public Builder pluginName(String pluginName) { + this.pluginName = pluginName; + return this; + } + public Builder apiGroups(String... apiGroups) { this.apiGroups = apiGroups; return this; @@ -126,7 +145,9 @@ public class Role extends AbstractExtension { } public PolicyRule build() { - return new PolicyRule(apiGroups, resources, resourceNames, nonResourceURLs, verbs); + return new PolicyRule(pluginName, apiGroups, resources, resourceNames, + nonResourceURLs, + verbs); } } } diff --git a/src/main/java/run/halo/app/plugin/ApiVersion.java b/src/main/java/run/halo/app/plugin/ApiVersion.java new file mode 100644 index 000000000..3026cb9e6 --- /dev/null +++ b/src/main/java/run/halo/app/plugin/ApiVersion.java @@ -0,0 +1,26 @@ +package run.halo.app.plugin; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Api version. + * + * @author guqing + * @since 2.0.0 + */ +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface ApiVersion { + + /** + * Api version value. + * + * @return api version string + */ + String value(); +} diff --git a/src/main/java/run/halo/app/plugin/HaloPluginManager.java b/src/main/java/run/halo/app/plugin/HaloPluginManager.java index ad0773dae..1de648c1f 100644 --- a/src/main/java/run/halo/app/plugin/HaloPluginManager.java +++ b/src/main/java/run/halo/app/plugin/HaloPluginManager.java @@ -183,7 +183,7 @@ public class HaloPluginManager extends DefaultPluginManager pluginWrapper.getPlugin().start(); - requestMappingManager.registerControllers(pluginWrapper); + requestMappingManager.registerHandlerMappings(pluginWrapper); pluginWrapper.setPluginState(PluginState.STARTED); pluginWrapper.setFailedException(null); @@ -259,7 +259,7 @@ public class HaloPluginManager extends DefaultPluginManager // create plugin instance and start it pluginWrapper.getPlugin().start(); - requestMappingManager.registerControllers(pluginWrapper); + requestMappingManager.registerHandlerMappings(pluginWrapper); pluginWrapper.setPluginState(PluginState.STARTED); startedPlugins.add(pluginWrapper); @@ -358,7 +358,7 @@ public class HaloPluginManager extends DefaultPluginManager */ public void releaseAdditionalResources(String pluginId) { // release request mapping - requestMappingManager.removeControllerMapping(pluginId); + requestMappingManager.removeHandlerMappings(pluginId); try { pluginApplicationInitializer.contextDestroyed(pluginId); } catch (Exception e) { diff --git a/src/main/java/run/halo/app/plugin/PluginAutoConfiguration.java b/src/main/java/run/halo/app/plugin/PluginAutoConfiguration.java index f9cf3e906..d524708a1 100644 --- a/src/main/java/run/halo/app/plugin/PluginAutoConfiguration.java +++ b/src/main/java/run/halo/app/plugin/PluginAutoConfiguration.java @@ -14,11 +14,12 @@ import org.pf4j.PluginLoader; import org.pf4j.PluginManager; import org.pf4j.PluginStatusProvider; import org.pf4j.RuntimeMode; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.util.StringUtils; -import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.reactive.accept.RequestedContentTypeResolver; /** * Plugin autoconfiguration for Spring Boot. @@ -32,18 +33,26 @@ import org.springframework.web.reactive.result.method.annotation.RequestMappingH public class PluginAutoConfiguration { private final PluginProperties pluginProperties; - - private final RequestMappingHandlerMapping requestMappingHandlerMapping; + @Qualifier("webFluxContentTypeResolver") + private final RequestedContentTypeResolver requestedContentTypeResolver; public PluginAutoConfiguration(PluginProperties pluginProperties, - RequestMappingHandlerMapping requestMappingHandlerMapping) { + RequestedContentTypeResolver requestedContentTypeResolver) { this.pluginProperties = pluginProperties; - this.requestMappingHandlerMapping = requestMappingHandlerMapping; + this.requestedContentTypeResolver = requestedContentTypeResolver; + } + + @Bean + public PluginRequestMappingHandlerMapping pluginRequestMappingHandlerMapping() { + PluginRequestMappingHandlerMapping mapping = new PluginRequestMappingHandlerMapping(); + mapping.setContentTypeResolver(requestedContentTypeResolver); + mapping.setOrder(-1); + return mapping; } @Bean public PluginRequestMappingManager pluginRequestMappingManager() { - return new PluginRequestMappingManager(requestMappingHandlerMapping); + return new PluginRequestMappingManager(pluginRequestMappingHandlerMapping()); } @Bean diff --git a/src/main/java/run/halo/app/plugin/PluginCompositeRouterFunction.java b/src/main/java/run/halo/app/plugin/PluginCompositeRouterFunction.java index ad4381c38..906bf5247 100644 --- a/src/main/java/run/halo/app/plugin/PluginCompositeRouterFunction.java +++ b/src/main/java/run/halo/app/plugin/PluginCompositeRouterFunction.java @@ -98,6 +98,9 @@ public class PluginCompositeRouterFunction implements RouterFunction> routerFunctions( PluginApplicationContext applicationContext) { + // TODO: Since the parent of the ApplicationContext of the plugin is RootApplicationContext + // obtaining the RouterFunction here will obtain the existing in the parent + // resulting in a loop when there is no matching route List> functions = applicationContext.getBeanProvider(RouterFunction.class) .orderedStream() diff --git a/src/main/java/run/halo/app/plugin/PluginRequestMappingHandlerMapping.java b/src/main/java/run/halo/app/plugin/PluginRequestMappingHandlerMapping.java new file mode 100644 index 000000000..c3f55e1e3 --- /dev/null +++ b/src/main/java/run/halo/app/plugin/PluginRequestMappingHandlerMapping.java @@ -0,0 +1,126 @@ +package run.halo.app.plugin; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.pf4j.PluginRuntimeException; +import org.springframework.aop.support.AopUtils; +import org.springframework.core.MethodIntrospector; +import org.springframework.stereotype.Controller; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.reactive.result.method.RequestMappingInfo; +import org.springframework.web.reactive.result.method.RequestMappingInfoHandlerMapping; +import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping; +import run.halo.app.extension.GroupVersion; + +/** + * An extension of {@link RequestMappingInfoHandlerMapping} that creates + * {@link RequestMappingInfo} instances from class-level and method-level + * {@link RequestMapping} annotations used by plugin. + * + * @author guqing + * @since 2.0.0 + */ +public class PluginRequestMappingHandlerMapping extends RequestMappingHandlerMapping { + + private final MultiValueMap pluginMappingInfo = + new LinkedMultiValueMap<>(); + + /** + * Register handler methods according to the plugin id and the controller(annotated + * {@link Controller}) bean. + * + * @param pluginId plugin id to be registered + * @param handler controller bean + */ + public void registerHandlerMethods(String pluginId, Object handler) { + Class handlerType = (handler instanceof String beanName + ? obtainApplicationContext().getType(beanName) : handler.getClass()); + + if (handlerType != null) { + final Class userType = ClassUtils.getUserClass(handlerType); + Map methods = MethodIntrospector.selectMethods(userType, + (MethodIntrospector.MetadataLookup) + method -> getPluginMappingForMethod(pluginId, method, userType)); + if (logger.isTraceEnabled()) { + logger.trace(formatMappings(userType, methods)); + } else if (mappingsLogger.isDebugEnabled()) { + mappingsLogger.debug(formatMappings(userType, methods)); + } + methods.forEach((method, mapping) -> { + Method invocableMethod = AopUtils.selectInvocableMethod(method, userType); + registerHandlerMethod(handler, invocableMethod, mapping); + pluginMappingInfo.add(pluginId, mapping); + }); + } + } + + private String formatMappings(Class userType, Map methods) { + String packageName = ClassUtils.getPackageName(userType); + String formattedType = (StringUtils.hasText(packageName) + ? Arrays.stream(packageName.split("\\.")) + .map(packageSegment -> packageSegment.substring(0, 1)) + .collect(Collectors.joining(".", "", "." + userType.getSimpleName())) : + userType.getSimpleName()); + Function methodFormatter = + method -> Arrays.stream(method.getParameterTypes()) + .map(Class::getSimpleName) + .collect(Collectors.joining(",", "(", ")")); + return methods.entrySet().stream() + .map(e -> { + Method method = e.getKey(); + return e.getValue() + ": " + method.getName() + methodFormatter.apply(method); + }) + .collect(Collectors.joining("\n\t", "\n\t" + formattedType + ":" + "\n\t", "")); + } + + /** + * Remove handler methods and mapping based on plugin id. + * + * @param pluginId plugin id + */ + public void unregister(String pluginId) { + Assert.notNull(pluginId, "The pluginId must not be null."); + if (!pluginMappingInfo.containsKey(pluginId)) { + return; + } + pluginMappingInfo.remove(pluginId).forEach(this::unregisterMapping); + } + + protected List getMappings(String pluginId) { + List requestMappingInfos = pluginMappingInfo.get(pluginId); + if (requestMappingInfos == null) { + return Collections.emptyList(); + } + return List.copyOf(requestMappingInfos); + } + + protected RequestMappingInfo getPluginMappingForMethod(String pluginId, + Method method, Class handlerType) { + RequestMappingInfo info = super.getMappingForMethod(method, handlerType); + if (info != null) { + ApiVersion apiVersion = handlerType.getAnnotation(ApiVersion.class); + if (apiVersion == null) { + throw new PluginRuntimeException( + "The handler [" + handlerType + "] is missing @ApiVersion annotation."); + } + info = RequestMappingInfo.paths(buildPrefix(pluginId, apiVersion.value())).build() + .combine(info); + } + return info; + } + + protected String buildPrefix(String pluginId, String version) { + GroupVersion groupVersion = GroupVersion.parseAPIVersion(version); + return String.format("/api/%s/plugins/%s", groupVersion.version(), pluginId); + } +} diff --git a/src/main/java/run/halo/app/plugin/PluginRequestMappingManager.java b/src/main/java/run/halo/app/plugin/PluginRequestMappingManager.java index 2c7122d1c..8b0bffffb 100644 --- a/src/main/java/run/halo/app/plugin/PluginRequestMappingManager.java +++ b/src/main/java/run/halo/app/plugin/PluginRequestMappingManager.java @@ -1,13 +1,10 @@ package run.halo.app.plugin; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; import java.util.Collection; import lombok.extern.slf4j.Slf4j; import org.pf4j.PluginWrapper; import org.springframework.context.support.GenericApplicationContext; import org.springframework.stereotype.Controller; -import org.springframework.util.ReflectionUtils; import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping; /** @@ -19,56 +16,25 @@ import org.springframework.web.reactive.result.method.annotation.RequestMappingH @Slf4j public class PluginRequestMappingManager { - private final RequestMappingHandlerMapping requestMappingHandlerMapping; + private final PluginRequestMappingHandlerMapping requestMappingHandlerMapping; public PluginRequestMappingManager( - RequestMappingHandlerMapping requestMappingHandlerMapping) { - this.requestMappingHandlerMapping = requestMappingHandlerMapping; + PluginRequestMappingHandlerMapping pluginRequestMappingHandlerMapping) { + this.requestMappingHandlerMapping = pluginRequestMappingHandlerMapping; } - public void registerControllers(PluginWrapper pluginWrapper) { + public void registerHandlerMappings(PluginWrapper pluginWrapper) { String pluginId = pluginWrapper.getPluginId(); getControllerBeans(pluginId) - .forEach(this::registerController); + .forEach(handler -> + requestMappingHandlerMapping.registerHandlerMethods(pluginId, handler)); } - private void registerController(Object controller) { - log.debug("Registering plugin request mapping for bean: [{}]", controller); - Method detectHandlerMethods = ReflectionUtils.findMethod(RequestMappingHandlerMapping.class, - "detectHandlerMethods", Object.class); - if (detectHandlerMethods == null) { - return; - } - try { - detectHandlerMethods.setAccessible(true); - detectHandlerMethods.invoke(requestMappingHandlerMapping, controller); - } catch (IllegalStateException ise) { - // ignore this - log.warn(ise.getMessage()); - } catch (IllegalAccessException e) { - throw new RuntimeException(e); - } catch (InvocationTargetException e) { - log.warn("invocation target exception: [{}]", e.getMessage(), e); - } + public void removeHandlerMappings(String pluginId) { + requestMappingHandlerMapping.unregister(pluginId); } - private void unregisterControllerMappingInternal(Object controller) { - requestMappingHandlerMapping.getHandlerMethods() - .forEach((mapping, handlerMethod) -> { - if (controller == handlerMethod.getBean()) { - log.debug("Removed plugin request mapping [{}] from bean [{}]", mapping, - controller); - requestMappingHandlerMapping.unregisterMapping(mapping); - } - }); - } - - public void removeControllerMapping(String pluginId) { - getControllerBeans(pluginId) - .forEach(this::unregisterControllerMappingInternal); - } - - public Collection getControllerBeans(String pluginId) { + private Collection getControllerBeans(String pluginId) { GenericApplicationContext pluginContext = ExtensionContextRegistry.getInstance().getByPluginId(pluginId); return pluginContext.getBeansWithAnnotation(Controller.class).values(); diff --git a/src/main/java/run/halo/app/security/authorization/Attributes.java b/src/main/java/run/halo/app/security/authorization/Attributes.java index caa2381d2..305fc26ea 100644 --- a/src/main/java/run/halo/app/security/authorization/Attributes.java +++ b/src/main/java/run/halo/app/security/authorization/Attributes.java @@ -65,4 +65,6 @@ public interface Attributes { * @return returns the path of the request */ String getPath(); + + String pluginName(); } diff --git a/src/main/java/run/halo/app/security/authorization/AttributesRecord.java b/src/main/java/run/halo/app/security/authorization/AttributesRecord.java index 994468af4..26de934c5 100644 --- a/src/main/java/run/halo/app/security/authorization/AttributesRecord.java +++ b/src/main/java/run/halo/app/security/authorization/AttributesRecord.java @@ -67,4 +67,9 @@ public class AttributesRecord implements Attributes { public String getPath() { return requestInfo.getPath(); } + + @Override + public String pluginName() { + return requestInfo.getPluginName(); + } } diff --git a/src/main/java/run/halo/app/security/authorization/RbacRequestEvaluation.java b/src/main/java/run/halo/app/security/authorization/RbacRequestEvaluation.java index 542fee3b2..cac12e71d 100644 --- a/src/main/java/run/halo/app/security/authorization/RbacRequestEvaluation.java +++ b/src/main/java/run/halo/app/security/authorization/RbacRequestEvaluation.java @@ -5,6 +5,7 @@ import java.util.Objects; import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.StringUtils; import run.halo.app.core.extension.Role; +import run.halo.app.core.extension.Role.PolicyRule; /** * @author guqing @@ -38,7 +39,8 @@ public class RbacRequestEvaluation { return verbMatches(rule, requestAttributes.getVerb()) && apiGroupMatches(rule, requestAttributes.getApiGroup()) && resourceMatches(rule, combinedResource, requestAttributes.getSubresource()) - && resourceNameMatches(rule, requestAttributes.getName()); + && resourceNameMatches(rule, requestAttributes.getName()) + && pluginNameMatches(rule, requestAttributes.pluginName()); } return verbMatches(rule, requestAttributes.getVerb()) && nonResourceURLMatches(rule, requestAttributes.getPath()); @@ -123,4 +125,8 @@ public class RbacRequestEvaluation { } return false; } + + protected boolean pluginNameMatches(PolicyRule rule, String pluginName) { + return StringUtils.equals(rule.getPluginName(), pluginName); + } } diff --git a/src/main/java/run/halo/app/security/authorization/RequestInfo.java b/src/main/java/run/halo/app/security/authorization/RequestInfo.java index 98ff1acd8..e1fee3fea 100644 --- a/src/main/java/run/halo/app/security/authorization/RequestInfo.java +++ b/src/main/java/run/halo/app/security/authorization/RequestInfo.java @@ -4,8 +4,11 @@ import java.util.Objects; import lombok.Getter; import lombok.ToString; import org.apache.commons.lang3.StringUtils; +import org.springframework.http.server.reactive.ServerHttpRequest; /** + * RequestInfo holds information parsed from the {@link ServerHttpRequest}. + * * @author guqing * @since 2.0.0 */ @@ -14,7 +17,7 @@ import org.apache.commons.lang3.StringUtils; public class RequestInfo { boolean isResourceRequest; final String path; - String namespace; + String pluginName; String verb; String apiPrefix; String apiGroup; @@ -28,14 +31,14 @@ public class RequestInfo { this(isResourceRequest, path, null, verb, null, null, null, null, null, null, null); } - public RequestInfo(boolean isResourceRequest, String path, String namespace, String verb, + public RequestInfo(boolean isResourceRequest, String path, String pluginName, String verb, String apiPrefix, String apiGroup, String apiVersion, String resource, String subresource, String name, String[] parts) { this.isResourceRequest = isResourceRequest; this.path = StringUtils.defaultString(path, ""); - this.namespace = StringUtils.defaultString(namespace, ""); + this.pluginName = StringUtils.defaultString(pluginName, ""); this.verb = StringUtils.defaultString(verb, ""); this.apiPrefix = StringUtils.defaultString(apiPrefix, ""); this.apiGroup = StringUtils.defaultString(apiGroup, ""); diff --git a/src/main/java/run/halo/app/security/authorization/RequestInfoFactory.java b/src/main/java/run/halo/app/security/authorization/RequestInfoFactory.java index c75137bfd..41d4ccece 100644 --- a/src/main/java/run/halo/app/security/authorization/RequestInfoFactory.java +++ b/src/main/java/run/halo/app/security/authorization/RequestInfoFactory.java @@ -8,6 +8,8 @@ import org.springframework.http.server.PathContainer; import org.springframework.http.server.reactive.ServerHttpRequest; /** + * Creates {@link RequestInfo} from {@link ServerHttpRequest}. + * * @author guqing * @since 2.0.0 */ @@ -25,6 +27,9 @@ public class RequestInfoFactory { */ final Set grouplessApiPrefixes; + /** + * special verbs no subresources. + */ final Set specialVerbs; public RequestInfoFactory(Set apiPrefixes, Set grouplessApiPrefixes) { @@ -39,39 +44,35 @@ public class RequestInfoFactory { } /** - * newRequestInfo returns the information from the http request. If error is not occurred, + *

newRequestInfo returns the information from the http request. If error is not occurred, * RequestInfo holds the information as best it is known before the failure * It handles both resource and non-resource requests and fills in all the pertinent - * information - * for each. - *

+ * information.

+ *

for each.

* Valid Inputs: - *

- * Resource paths + *

Resource paths

*
-     * /apis/{api-group}/{version}/namespaces
-     * /api/{version}/namespaces
-     * /api/{version}/namespaces/{namespace}
-     * /api/{version}/namespaces/{namespace}/{resource}
-     * /api/{version}/namespaces/{namespace}/{resource}/{resourceName}
+     * /api/{version}/plugins
+     * /api/{version}/plugins/{pluginName}
+     * /api/{version}/plugins/{pluginName}/{resource}
+     * /api/{version}/plugins/{pluginName}/{resource}/{resourceName}
      * /api/{version}/{resource}
      * /api/{version}/{resource}/{resourceName}
      * 
- * + *

Special verbs without subresources:

*
-     * Special verbs without subresources:
      * /api/{version}/proxy/{resource}/{resourceName}
      * /api/{version}/proxy/namespaces/{namespace}/{resource}/{resourceName}
      * 
* + *

Special verbs with subresources:

*
-     * Special verbs with subresources:
      * /api/{version}/watch/{resource}
      * /api/{version}/watch/namespaces/{namespace}/{resource}
      * 
* + *

NonResource paths:

*
-     * NonResource paths
      * /apis/{api-group}/{version}
      * /apis/{api-group}
      * /apis
@@ -137,29 +138,29 @@ public class RequestInfoFactory {
                 default -> "";
             };
         }
-        Set namespaceSubresources = Set.of("status", "finalize");
-        // URL forms: /namespaces/{namespace}/{kind}/*, where parts are adjusted to be relative
+        // URL forms: /plugins/{plugin-name}/{kind}/*, where parts are adjusted to be relative
         // to kind
-        if (Objects.equals(currentParts[0], "namespaces")) {
+        if (Objects.equals(currentParts[0], "plugins")
+            && StringUtils.isEmpty(requestInfo.getApiGroup())) {
             if (currentParts.length > 1) {
-                requestInfo.namespace = currentParts[1];
+                requestInfo.pluginName = currentParts[1];
 
-                // if there is another step after the namespace name and it is not a known
-                // namespace subresource
+                // if there is another step after the plugin name and it is not a known
+                // plugins subresource
                 // move currentParts to include it as a resource in its own right
-                if (currentParts.length > 2 && !namespaceSubresources.contains(currentParts[2])) {
+                if (currentParts.length > 2) {
                     currentParts = Arrays.copyOfRange(currentParts, 2, currentParts.length);
                 }
             }
         } else {
-            requestInfo.namespace = "";
+            requestInfo.pluginName = "";
         }
 
         // parsing successful, so we now know the proper value for .Parts
         requestInfo.parts = currentParts;
-        Set specialVerbsNoSubresources = Set.of("proxy");
+        // special verbs no subresources
         // parts look like: resource/resourceName/subresource/other/stuff/we/don't/interpret
-        if (requestInfo.parts.length >= 3 && !specialVerbsNoSubresources.contains(
+        if (requestInfo.parts.length >= 3 && !specialVerbs.contains(
             requestInfo.verb)) {
             requestInfo.subresource = requestInfo.parts[2];
         }
@@ -195,7 +196,7 @@ public class RequestInfoFactory {
         return "1".equals(requestParam) || "true".equals(requestParam);
     }
 
-    public String[] splitPath(String path) {
+    private String[] splitPath(String path) {
         path = StringUtils.strip(path, "/");
         if (StringUtils.isEmpty(path)) {
             return new String[] {};
diff --git a/src/test/java/run/halo/app/config/ExtensionConfigurationTest.java b/src/test/java/run/halo/app/config/ExtensionConfigurationTest.java
index 0b273ed08..929bbd2ff 100644
--- a/src/test/java/run/halo/app/config/ExtensionConfigurationTest.java
+++ b/src/test/java/run/halo/app/config/ExtensionConfigurationTest.java
@@ -42,10 +42,11 @@ class ExtensionConfigurationTest {
     @BeforeEach
     void setUp() {
         // disable authorization
-        var rule = new Role.PolicyRule();
-        rule.setApiGroups(new String[] {"*"});
-        rule.setResources(new String[] {"*"});
-        rule.setVerbs(new String[] {"*"});
+        var rule = new Role.PolicyRule.Builder()
+            .apiGroups("*")
+            .resources("*")
+            .verbs("*")
+            .build();
         var role = new Role();
         role.setRules(List.of(rule));
         when(roleService.getRole(anyString())).thenReturn(role);
diff --git a/src/test/java/run/halo/app/plugin/PluginRequestMappingHandlerMappingTest.java b/src/test/java/run/halo/app/plugin/PluginRequestMappingHandlerMappingTest.java
new file mode 100644
index 000000000..58d74867c
--- /dev/null
+++ b/src/test/java/run/halo/app/plugin/PluginRequestMappingHandlerMappingTest.java
@@ -0,0 +1,301 @@
+package run.halo.app.plugin;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.springframework.mock.http.server.reactive.MockServerHttpRequest.get;
+import static org.springframework.mock.http.server.reactive.MockServerHttpRequest.post;
+import static org.springframework.web.bind.annotation.RequestMethod.GET;
+import static org.springframework.web.bind.annotation.RequestMethod.HEAD;
+
+import java.lang.reflect.Method;
+import java.security.Principal;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.pf4j.PluginRuntimeException;
+import org.springframework.core.MethodIntrospector;
+import org.springframework.core.annotation.AnnotatedElementUtils;
+import org.springframework.http.HttpMethod;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.stereotype.Controller;
+import org.springframework.util.Assert;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.context.support.StaticWebApplicationContext;
+import org.springframework.web.method.HandlerMethod;
+import org.springframework.web.reactive.result.method.RequestMappingInfo;
+import org.springframework.web.server.MethodNotAllowedException;
+import org.springframework.web.server.ServerWebExchange;
+import org.springframework.web.util.pattern.PathPatternParser;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+/**
+ * Tests for {@link PluginRequestMappingHandlerMapping}.
+ *
+ * @author guqing
+ * @since 2.0.0
+ */
+class PluginRequestMappingHandlerMappingTest {
+
+    private final StaticWebApplicationContext wac = new StaticWebApplicationContext();
+
+    private PluginRequestMappingHandlerMapping handlerMapping;
+
+
+    @BeforeEach
+    public void setup() {
+        handlerMapping = new PluginRequestMappingHandlerMapping();
+        this.handlerMapping.setApplicationContext(wac);
+    }
+
+    @Test
+    public void shouldAddPathPrefixWhenExistingApiVersion() throws Exception {
+        Method method = UserController.class.getMethod("getUser");
+        RequestMappingInfo info =
+            this.handlerMapping.getPluginMappingForMethod("fakePlugin", method,
+                UserController.class);
+
+        assertThat(info).isNotNull();
+        assertThat(info.getPatternsCondition().getPatterns()).isEqualTo(
+            Collections.singleton(
+                new PathPatternParser().parse("/api/v1alpha1/plugins/fakePlugin/user/{id}")));
+    }
+
+    @Test
+    public void shouldFailWhenMissingApiVersion() throws Exception {
+        Method method = AppleMissingApiVersionController.class.getMethod("getName");
+        assertThatThrownBy(() ->
+            this.handlerMapping.getPluginMappingForMethod("fakePlugin", method,
+                AppleMissingApiVersionController.class)).isInstanceOf(PluginRuntimeException.class)
+            .hasMessage(
+                "The handler [class run.halo.app.plugin"
+                    + ".PluginRequestMappingHandlerMappingTest$AppleMissingApiVersionController] "
+                    + "is missing @ApiVersion annotation.");
+    }
+
+    @Test
+    void registerHandlerMethods() {
+        assertThat(handlerMapping.getMappings("fakePlugin")).isEmpty();
+
+        UserController userController = mock(UserController.class);
+        handlerMapping.registerHandlerMethods("fakePlugin", userController);
+
+        List mappings = handlerMapping.getMappings("fakePlugin");
+        assertThat(mappings).hasSize(1);
+        assertThat(mappings.get(0).toString()).isEqualTo(
+            "{GET /api/v1alpha1/plugins/fakePlugin/user/{id}}");
+    }
+
+    @Test
+    void unregister() {
+        UserController userController = mock(UserController.class);
+        // register handler methods first
+        handlerMapping.registerHandlerMethods("fakePlugin", userController);
+        assertThat(handlerMapping.getMappings("fakePlugin")).hasSize(1);
+
+        // unregister
+        handlerMapping.unregister("fakePlugin");
+        assertThat(handlerMapping.getMappings("fakePlugin")).isEmpty();
+    }
+
+    @Test
+    public void getHandlerDirectMatch() {
+        // register handler methods first
+        handlerMapping.registerHandlerMethods("fakePlugin", new TestController());
+
+        // resolve an expected method from TestController
+        Method expected =
+            ResolvableMethod.on(TestController.class).annot(getMapping("/foo")).build();
+
+        // get handler by mock exchange
+        ServerWebExchange exchange =
+            MockServerWebExchange.from(get("/api/v1alpha1/plugins/fakePlugin/foo"));
+        HandlerMethod hm = (HandlerMethod) this.handlerMapping.getHandler(exchange).block();
+
+        assertThat(hm).isNotNull();
+        assertThat(hm.getMethod()).isEqualTo(expected);
+    }
+
+    @Test
+    public void getHandlerBestMatch() {
+        // register handler methods first
+        handlerMapping.registerHandlerMethods("fakePlugin", new TestController());
+
+        Method expected =
+            ResolvableMethod.on(TestController.class).annot(getMapping("/foo").params("p")).build();
+
+        String requestPath = "/api/v1alpha1/plugins/fakePlugin/foo?p=anything";
+        ServerWebExchange exchange = MockServerWebExchange.from(get(requestPath));
+        HandlerMethod hm = (HandlerMethod) this.handlerMapping.getHandler(exchange).block();
+
+        assertThat(hm).isNotNull();
+        assertThat(hm.getMethod()).isEqualTo(expected);
+    }
+
+    @Test
+    public void getHandlerRootPathMatch() {
+        // register handler methods first
+        handlerMapping.registerHandlerMethods("fakePlugin", new TestController());
+        Method expected =
+            ResolvableMethod.on(TestController.class).annot(getMapping("")).build();
+
+        String requestPath = "/api/v1alpha1/plugins/fakePlugin";
+        ServerWebExchange exchange = MockServerWebExchange.from(get(requestPath));
+        HandlerMethod hm = (HandlerMethod) this.handlerMapping.getHandler(exchange).block();
+
+        assertThat(hm).isNotNull();
+        assertThat(hm.getMethod()).isEqualTo(expected);
+    }
+
+    @Test
+    public void getHandlerRequestMethodNotAllowed() {
+        // register handler methods first
+        handlerMapping.registerHandlerMethods("fakePlugin", new TestController());
+
+        String requestPath = "/api/v1alpha1/plugins/fakePlugin/bar";
+        ServerWebExchange exchange = MockServerWebExchange.from(post(requestPath));
+        Mono mono = this.handlerMapping.getHandler(exchange);
+
+        assertError(mono, MethodNotAllowedException.class,
+            ex -> assertThat(ex.getSupportedMethods()).isEqualTo(
+                Set.of(HttpMethod.GET, HttpMethod.HEAD)));
+    }
+
+    @SuppressWarnings("unchecked")
+    private  void assertError(Mono mono, final Class exceptionClass,
+        final Consumer consumer) {
+        StepVerifier.create(mono)
+            .consumeErrorWith(error -> {
+                assertThat(error.getClass()).isEqualTo(exceptionClass);
+                consumer.accept((T) error);
+            })
+            .verify();
+    }
+
+    private RequestMappingPredicate getMapping(String... path) {
+        return new RequestMappingPredicate(path).method(GET).params();
+    }
+
+    public static class ResolvableMethod {
+        private final Class objectClass;
+        private final List> filters = new ArrayList<>(4);
+
+        public ResolvableMethod(Class objectClass) {
+            this.objectClass = objectClass;
+        }
+
+        public static ResolvableMethod on(Class objectClass) {
+            return new ResolvableMethod(objectClass);
+        }
+
+        public ResolvableMethod annot(Predicate predicate) {
+            filters.add(predicate);
+            return this;
+        }
+
+        public Method build() {
+            Set methods = MethodIntrospector.selectMethods(this.objectClass, this::isMatch);
+            Assert.state(!methods.isEmpty(), () -> "No matching method: " + this);
+            Assert.state(methods.size() == 1,
+                () -> "Multiple matching methods: " + this + formatMethods(methods));
+            return methods.iterator().next();
+        }
+
+        private String formatMethods(Set methods) {
+            return "\nMatched:\n" + methods.stream()
+                .map(Method::toGenericString).collect(Collectors.joining(",\n\t", "[\n\t", "\n]"));
+        }
+
+        private boolean isMatch(Method method) {
+            return this.filters.stream().allMatch(p -> p.test(method));
+        }
+    }
+
+    public static class RequestMappingPredicate implements Predicate {
+
+        private final String[] path;
+
+        private RequestMethod[] method = {};
+
+        private String[] params;
+
+
+        private RequestMappingPredicate(String... path) {
+            this.path = path;
+        }
+
+
+        public RequestMappingPredicate method(RequestMethod... methods) {
+            this.method = methods;
+            return this;
+        }
+
+        public RequestMappingPredicate params(String... params) {
+            this.params = params;
+            return this;
+        }
+
+        @Override
+        public boolean test(Method method) {
+            RequestMapping annot =
+                AnnotatedElementUtils.findMergedAnnotation(method, RequestMapping.class);
+            return annot != null
+                && Arrays.equals(this.path, annot.path())
+                && Arrays.equals(this.method, annot.method())
+                && (this.params == null || Arrays.equals(this.params, annot.params()));
+        }
+    }
+
+    @ApiVersion("v1alpha1")
+    @RestController
+    @RequestMapping("/user")
+    static class UserController {
+
+        @GetMapping("/{id}")
+        public Principal getUser() {
+            return mock(Principal.class);
+        }
+    }
+
+    @RestController
+    @RequestMapping("/apples")
+    static class AppleMissingApiVersionController {
+
+        @GetMapping
+        public String getName() {
+            return mock(String.class);
+        }
+    }
+
+    @ApiVersion("v1alpha1")
+    @Controller
+    @RequestMapping
+    static class TestController {
+        @GetMapping("/foo")
+        public void foo() {
+        }
+
+        @GetMapping(path = "/foo", params = "p")
+        public void fooParam() {
+        }
+
+        @RequestMapping(path = "/ba*", method = {GET, HEAD})
+        public void bar() {
+        }
+
+        @GetMapping("")
+        public void empty() {
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/test/java/run/halo/app/security/authorization/DefaultRoleBindingServiceTest.java b/src/test/java/run/halo/app/security/authorization/DefaultRoleBindingServiceTest.java
index 9b31222e4..890fcf520 100644
--- a/src/test/java/run/halo/app/security/authorization/DefaultRoleBindingServiceTest.java
+++ b/src/test/java/run/halo/app/security/authorization/DefaultRoleBindingServiceTest.java
@@ -18,7 +18,6 @@ import run.halo.app.core.extension.service.DefaultRoleBindingService;
  * @author guqing
  * @since 2.0.0
  */
-// @ExtendWith(SpringExtension.class)
 public class DefaultRoleBindingServiceTest {
 
     private DefaultRoleBindingService roleBindingLister;
diff --git a/src/test/java/run/halo/app/security/authorization/PolicyRuleTest.java b/src/test/java/run/halo/app/security/authorization/PolicyRuleTest.java
index 42631f496..95d6c19a5 100644
--- a/src/test/java/run/halo/app/security/authorization/PolicyRuleTest.java
+++ b/src/test/java/run/halo/app/security/authorization/PolicyRuleTest.java
@@ -5,8 +5,10 @@ import static org.assertj.core.api.Assertions.assertThat;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import org.json.JSONException;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.skyscreamer.jsonassert.JSONAssert;
 import run.halo.app.core.extension.Role;
 import run.halo.app.infra.utils.JsonUtils;
 
@@ -25,21 +27,38 @@ class PolicyRuleTest {
     }
 
     @Test
-    public void constructPolicyRule() throws JsonProcessingException {
-        Role.PolicyRule policyRule = new Role.PolicyRule(null, null, null, null, null);
+    public void constructPolicyRule() throws JsonProcessingException, JSONException {
+        Role.PolicyRule policyRule = new Role.PolicyRule(null, null, null, null, null, null);
         assertThat(policyRule).isNotNull();
-        JsonNode policyRuleJson = objectMapper.valueToTree(policyRule);
-        assertThat(policyRuleJson).isEqualTo(objectMapper.readTree("""
-            {"apiGroups":[],"resources":[],"resourceNames":[],"nonResourceURLs":[],"verbs":[]}
-            """));
+        JSONAssert.assertEquals("""
+            {
+                "pluginName": "",
+                "apiGroups": [],
+                "resources": [],
+                "resourceNames": [],
+                "nonResourceURLs": [],
+                "verbs": []
+            }
+            """,
+            JsonUtils.objectToJson(policyRule),
+            true);
 
         Role.PolicyRule policyByBuilder = new Role.PolicyRule.Builder().build();
-        JsonNode policyByBuilderJson = objectMapper.valueToTree(policyByBuilder);
-        assertThat(policyByBuilderJson).isEqualTo(objectMapper.readTree("""
-            {"apiGroups":[],"resources":[],"resourceNames":[],"nonResourceURLs":[],"verbs":[]}
-             """));
+        JSONAssert.assertEquals("""
+            {
+                "pluginName": "",
+                "apiGroups": [],
+                "resources": [],
+                "resourceNames": [],
+                "nonResourceURLs": [],
+                "verbs": []
+            }
+            """,
+            JsonUtils.objectToJson(policyByBuilder),
+            true);
 
         Role.PolicyRule policyNonNull = new Role.PolicyRule.Builder()
+            .pluginName("fakePluginName")
             .apiGroups("group")
             .resources("resource-1", "resource-2")
             .resourceNames("resourceName")
@@ -49,6 +68,7 @@ class PolicyRuleTest {
 
         JsonNode expected = objectMapper.readTree("""
             {
+                "pluginName": "fakePluginName",
                 "apiGroups": [
                     "group"
                 ],
diff --git a/src/test/java/run/halo/app/security/authorization/RequestInfoResolverTest.java b/src/test/java/run/halo/app/security/authorization/RequestInfoResolverTest.java
index 4307b0036..4fda9152b 100644
--- a/src/test/java/run/halo/app/security/authorization/RequestInfoResolverTest.java
+++ b/src/test/java/run/halo/app/security/authorization/RequestInfoResolverTest.java
@@ -85,6 +85,67 @@ public class RequestInfoResolverTest {
         });
     }
 
+    @Test
+    void pluginsScopedAndPluginManage() {
+        List testCases =
+            List.of(new SuccessCase("DELETE", "/apis/extensions/v1/plugins/other/posts",
+                    "delete", "apis", "extensions", "v1", "", "plugins", "posts", "other",
+                    new String[] {"plugins", "other", "posts"}),
+
+                // api group identification
+                new SuccessCase("POST", "/apis/extensions/v1/plugins/other/posts", "create", "apis",
+                    "extensions", "v1", "", "plugins", "posts", "other",
+                    new String[] {"plugins", "other", "posts"}),
+
+                // api version identification
+                new SuccessCase("POST", "/apis/extensions/v1beta3/plugins/other/posts", "create",
+                    "apis", "extensions", "v1beta3", "", "plugins", "posts", "other",
+                    new String[] {"plugins", "other", "posts"}));
+
+        // 以 /apis 开头的 plugins 资源为 core 中管理插件使用的资源
+        for (SuccessCase successCase : testCases) {
+            var request =
+                method(HttpMethod.valueOf(successCase.method),
+                    successCase.url).build();
+            RequestInfo requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request);
+            assertThat(requestInfo).isNotNull();
+            assertRequestInfoCase(successCase, requestInfo);
+        }
+
+        List pluginScopedCases =
+            List.of(new SuccessCase("DELETE", "/api/v1/plugins/other/posts",
+                    "deletecollection", "api", "", "v1", "other", "posts", "", "",
+                    new String[] {"posts"}),
+
+                // api group identification
+                new SuccessCase("POST", "/api/v1/plugins/other/posts", "create", "api",
+                    "", "v1", "other", "posts", "", "", new String[] {"posts"}),
+
+                // api version identification
+                new SuccessCase("POST", "/api/v1beta3/plugins/other/posts", "create",
+                    "api", "", "v1beta3", "other", "posts", "", "",
+                    new String[] {"posts"}));
+
+        for (SuccessCase pluginScopedCase : pluginScopedCases) {
+            var request =
+                method(HttpMethod.valueOf(pluginScopedCase.method),
+                    pluginScopedCase.url).build();
+            RequestInfo requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request);
+            assertThat(requestInfo).isNotNull();
+            assertRequestInfoCase(pluginScopedCase, requestInfo);
+        }
+    }
+
+    private void assertRequestInfoCase(SuccessCase pluginScopedCase, RequestInfo requestInfo) {
+        assertThat(requestInfo.getPluginName()).isEqualTo(pluginScopedCase.expectedPluginName);
+        assertThat(requestInfo.getVerb()).isEqualTo(pluginScopedCase.expectedVerb);
+        assertThat(requestInfo.getParts()).isEqualTo(pluginScopedCase.expectedParts);
+        assertThat(requestInfo.getApiGroup()).isEqualTo(pluginScopedCase.expectedAPIGroup);
+        assertThat(requestInfo.getResource()).isEqualTo(pluginScopedCase.expectedResource);
+        assertThat(requestInfo.getSubresource())
+            .isEqualTo(pluginScopedCase.expectedSubresource());
+    }
+
     @Test
     public void errorCaseTest() {
         List errorCases = List.of(new ErrorCases("no resource path", "/"),
@@ -113,6 +174,10 @@ public class RequestInfoResolverTest {
         List rules = List.of(
             new PolicyRule.Builder().apiGroups("").resources("posts").verbs("list", "get")
                 .build(),
+            new PolicyRule.Builder().pluginName("fakePlugin").apiGroups("").resources("posts")
+                .verbs("list", "get").build(),
+            new PolicyRule.Builder().pluginName("fakePlugin").apiGroups("")
+                .resources("posts/tags").verbs("list", "get").build(),
             new PolicyRule.Builder().apiGroups("").resources("categories").verbs("*").build(),
             new PolicyRule.Builder().nonResourceURLs("/healthy").verbs("get", "post", "head")
                 .build());
@@ -165,6 +230,11 @@ public class RequestInfoResolverTest {
             new RequestResolveCase("/api/v1/posts", "DELETE", false),
             new RequestResolveCase("/api/v1/posts/aName", "UPDATE", false),
 
+            // plugin resource
+            new RequestResolveCase("/api/v1/plugins/fakePlugin/posts", "GET", true),
+            new RequestResolveCase("/api/v1/plugins/fakePlugin/posts/some-name", "GET", true),
+            new RequestResolveCase("/api/v1/plugins/fakePlugin/posts/some-name/tags", "GET", true),
+
             // group resource url
             new RequestResolveCase("/apis/group/v1/posts", "GET", false),
 
@@ -185,7 +255,7 @@ public class RequestInfoResolverTest {
 
     public record SuccessCase(String method, String url, String expectedVerb,
                               String expectedAPIPrefix, String expectedAPIGroup,
-                              String expectedAPIVersion, String expectedNamespace,
+                              String expectedAPIVersion, String expectedPluginName,
                               String expectedResource, String expectedSubresource,
                               String expectedName, String[] expectedParts) {
     }
@@ -194,31 +264,31 @@ public class RequestInfoResolverTest {
     List getTestRequestInfos() {
         String namespaceAll = "*";
         return List.of(
-            new SuccessCase("GET", "/api/v1/namespaces", "list", "api", "", "v1", "", "namespaces",
-                "", "", new String[] {"namespaces"}),
-            new SuccessCase("GET", "/api/v1/namespaces/other", "get", "api", "", "v1", "other",
-                "namespaces", "", "other", new String[] {"namespaces", "other"}),
+            new SuccessCase("GET", "/api/v1/plugins", "list", "api", "", "v1", "", "plugins",
+                "", "", new String[] {"plugins"}),
+            new SuccessCase("GET", "/api/v1/plugins/other", "get", "api", "", "v1", "other",
+                "plugins", "", "other", new String[] {"plugins", "other"}),
 
-            new SuccessCase("GET", "/api/v1/namespaces/other/posts", "list", "api", "", "v1",
+            new SuccessCase("GET", "/api/v1/plugins/other/posts", "list", "api", "", "v1",
                 "other", "posts", "", "", new String[] {"posts"}),
-            new SuccessCase("GET", "/api/v1/namespaces/other/posts/foo", "get", "api", "", "v1",
+            new SuccessCase("GET", "/api/v1/plugins/other/posts/foo", "get", "api", "", "v1",
                 "other", "posts", "", "foo", new String[] {"posts", "foo"}),
-            new SuccessCase("HEAD", "/api/v1/namespaces/other/posts/foo", "get", "api", "", "v1",
+            new SuccessCase("HEAD", "/api/v1/plugins/other/posts/foo", "get", "api", "", "v1",
                 "other", "posts", "", "foo", new String[] {"posts", "foo"}),
             new SuccessCase("GET", "/api/v1/posts", "list", "api", "", "v1", namespaceAll, "posts",
                 "", "", new String[] {"posts"}),
             new SuccessCase("HEAD", "/api/v1/posts", "list", "api", "", "v1", namespaceAll, "posts",
                 "", "", new String[] {"posts"}),
-            new SuccessCase("GET", "/api/v1/namespaces/other/posts/foo", "get", "api", "", "v1",
+            new SuccessCase("GET", "/api/v1/plugins/other/posts/foo", "get", "api", "", "v1",
                 "other", "posts", "", "foo", new String[] {"posts", "foo"}),
-            new SuccessCase("GET", "/api/v1/namespaces/other/posts", "list", "api", "", "v1",
+            new SuccessCase("GET", "/api/v1/plugins/other/posts", "list", "api", "", "v1",
                 "other", "posts", "", "", new String[] {"posts"}),
 
             // special verbs
-            new SuccessCase("GET", "/api/v1/proxy/namespaces/other/posts/foo", "proxy", "api", "",
+            new SuccessCase("GET", "/api/v1/proxy/plugins/other/posts/foo", "proxy", "api", "",
                 "v1", "other", "posts", "", "foo", new String[] {"posts", "foo"}),
             new SuccessCase("GET",
-                "/api/v1/proxy/namespaces/other/posts/foo/subpath/not/a/subresource", "proxy",
+                "/api/v1/proxy/plugins/other/posts/foo/subpath/not/a/subresource", "proxy",
                 "api", "", "v1", "other", "posts", "", "foo",
                 new String[] {"posts", "foo", "subpath", "not", "a", "subresource"}),
             new SuccessCase("GET", "/api/v1/watch/posts", "watch", "api", "", "v1", namespaceAll,
@@ -227,52 +297,35 @@ public class RequestInfoResolverTest {
                 namespaceAll, "posts", "", "", new String[] {"posts"}),
             new SuccessCase("GET", "/api/v1/posts?watch=false", "list", "api", "", "v1",
                 namespaceAll, "posts", "", "", new String[] {"posts"}),
-            new SuccessCase("GET", "/api/v1/watch/namespaces/other/posts", "watch", "api", "", "v1",
+            new SuccessCase("GET", "/api/v1/watch/plugins/other/posts", "watch", "api", "", "v1",
                 "other", "posts", "", "", new String[] {"posts"}),
-            new SuccessCase("GET", "/api/v1/namespaces/other/posts?watch=1", "watch", "api", "",
+            new SuccessCase("GET", "/api/v1/plugins/other/posts?watch=1", "watch", "api", "",
                 "v1", "other", "posts", "", "", new String[] {"posts"}),
-            new SuccessCase("GET", "/api/v1/namespaces/other/posts?watch=0", "list", "api", "",
+            new SuccessCase("GET", "/api/v1/plugins/other/posts?watch=0", "list", "api", "",
                 "v1", "other", "posts", "", "", new String[] {"posts"}),
 
             // subresource identification
-            new SuccessCase("GET", "/api/v1/namespaces/other/posts/foo/status", "get", "api", "",
+            new SuccessCase("GET", "/api/v1/plugins/other/posts/foo/status", "get", "api", "",
                 "v1", "other", "posts", "status", "foo", new String[] {"posts", "foo", "status"}),
-            new SuccessCase("GET", "/api/v1/namespaces/other/posts/foo/proxy/subpath", "get", "api",
+            new SuccessCase("GET", "/api/v1/plugins/other/posts/foo/proxy/subpath", "get", "api",
                 "", "v1", "other", "posts", "proxy", "foo",
                 new String[] {"posts", "foo", "proxy", "subpath"}),
-            new SuccessCase("PUT", "/api/v1/namespaces/other/finalize", "update", "api", "", "v1",
-                "other", "namespaces", "finalize", "other",
-                new String[] {"namespaces", "other", "finalize"}),
-            new SuccessCase("PUT", "/api/v1/namespaces/other/status", "update", "api", "", "v1",
-                "other", "namespaces", "status", "other",
-                new String[] {"namespaces", "other", "status"}),
 
             // verb identification
-            new SuccessCase("PATCH", "/api/v1/namespaces/other/posts/foo", "patch", "api", "", "v1",
+            new SuccessCase("PATCH", "/api/v1/plugins/other/posts/foo", "patch", "api", "", "v1",
                 "other", "posts", "", "foo", new String[] {"posts", "foo"}),
-            new SuccessCase("DELETE", "/api/v1/namespaces/other/posts/foo", "delete", "api", "",
+            new SuccessCase("DELETE", "/api/v1/plugins/other/posts/foo", "delete", "api", "",
                 "v1", "other", "posts", "", "foo", new String[] {"posts", "foo"}),
-            new SuccessCase("POST", "/api/v1/namespaces/other/posts", "create", "api", "", "v1",
+            new SuccessCase("POST", "/api/v1/plugins/other/posts", "create", "api", "", "v1",
                 "other", "posts", "", "", new String[] {"posts"}),
 
             // deletecollection verb identification
             new SuccessCase("DELETE", "/api/v1/nodes", "deletecollection", "api", "", "v1", "",
                 "nodes", "", "", new String[] {"nodes"}),
-            new SuccessCase("DELETE", "/api/v1/namespaces", "deletecollection", "api", "", "v1", "",
-                "namespaces", "", "", new String[] {"namespaces"}),
-            new SuccessCase("DELETE", "/api/v1/namespaces/other/posts", "deletecollection", "api",
-                "", "v1", "other", "posts", "", "", new String[] {"posts"}),
-            new SuccessCase("DELETE", "/apis/extensions/v1/namespaces/other/posts",
-                "deletecollection", "apis", "extensions", "v1", "other", "posts", "", "",
-                new String[] {"posts"}),
-
-            // api group identification
-            new SuccessCase("POST", "/apis/extensions/v1/namespaces/other/posts", "create", "apis",
-                "extensions", "v1", "other", "posts", "", "", new String[] {"posts"}),
-
-            // api version identification
-            new SuccessCase("POST", "/apis/extensions/v1beta3/namespaces/other/posts", "create",
-                "apis", "extensions", "v1beta3", "other", "posts", "", "", new String[] {"posts"}));
+            new SuccessCase("DELETE", "/api/v1/plugins", "deletecollection", "api", "", "v1", "",
+                "plugins", "", "", new String[] {"plugins"}),
+            new SuccessCase("DELETE", "/api/v1/plugins/other/posts", "deletecollection", "api",
+                "", "v1", "other", "posts", "", "", new String[] {"posts"}));
     }
 
 }