Closed cjcenizal closed 4 months ago
Pinging @elastic/kibana-platform (Team:Platform)
This is the idea I had in mind for supporting middleware. In my opinion, it would be the most flexible way to extend the current router and not have to worry about x-pack code (all x-pack plugin should have "licensing" as dependencies under their kibana.json
file).
const licenseCheck = licenseService.getLicenseCheck({ pluginId: 'myApp', minimumLicenseType: 'basic' });
// method could be "*" to match all
router.pre({ method: 'get', path: 'my-app/*' }, [licenseCheck]);
Simplifying license consumption and behavior is great idea. I think we should make this easier for x-pack plugins. Just one less thing developers should need to worry about! Have some thoughts to dump here:
With the size of the Kibana codebase, I worry that allowing plugins to register global middleware is going to cause significant problems. The only case we've allowed so far is allowing a plugin to register an authentication mechanism (for Security) which makes sense from a global behavior perspective.
What I would prefer is an opt-in mechanism that plugins can utilize. The main reason I prefer this is that it enables gradual or piece-meal adoption by plugins. This avoids problems where we have to make this middleware work with all plugins and their specific needs. It also makes changing this mechanism a bit simpler.
That said, @sebelga's approach does fit that bill if the middleware is isolated to a single plugin's Router. However, I would like to propose a different approach which does not require any changes to Core's Router, which is to use a wrapper around IRouter:
class Plugin {
setup(core, plugins) {
// Licensing plugin provides a wrapper for Http's IRouter
const licensedRouter = plugins.licensing.createLicensedRouter(
core.http.createRouter()
);
licenseRouter.get(
{
path: '/my-route',
validate: /** schema */,
// The types for this wrapper could require a license level
// in the route config
licenseRequired: 'gold'
},
async (context, req, res) => { /** normal route body */ }
)
}
}
Benefits of this approach:
licenseRequired
).I like Josh's proposal. We've taken a similar approach with the Security and Spaces plugins to date, by creating a less flexible "licensed route handler" which encapsulates the logic for our routes. It still requires us to call the factory function though, we we have to remember to do:
@joshdover what if the licensing plugin provides withLicense
decorator that implements the same logic? like proposed in our example https://github.com/elastic/kibana/blob/master/src/core/MIGRATION_EXAMPLES.md#simple-example
function withLicense(level: LicenseType){...}
licenseRouter.get(
{
path: '/my-route',
validate: ...
},
withLicense('gold')(async (context, req, res) => { ... })
)
}
that would allow us to compose behaviour via HOF.
I think that would work too, and maybe we should do both, but one advantage the IRouter wrapper is that you can require all routes in the plugin to specify a license level. Or you could even have an option to use the same default license level for all routes in the plugin, and just override the level for some key routes.
With the size of the Kibana codebase, I worry that allowing plugins to register global middleware is going to cause significant problems.
I'd need more context to understand the problem. But yes, my idea is that a plugin gets an isolate "slice" of the router, and its middleware does not affect other plugin.
The solution that you propose does not solve the main problem that I'd like to address: define one or multiple middleware against a URL pattern (regEx).
We are now trying to find a solution for "license". In the past, you had to find a solution for "security", maybe tomorrow we need a solution to intercept "metrics" and do something with them. Giving flexibility to the consumer to intercept the request and do something with it seems like the most flexible approach.
From the core router perspective, it is just an array of functions to be executed sequentially until an error is thrown or response
returns something. With that said, if you prefer not to add this functionality in the core router, we can extend the router ourselves and add it in our es-shared-ui
plugin.
As for the convention on how to extend, I like your suggestion as we can nest the extensions
class Plugin {
setup(core, plugins) {
// Licensing plugin provides a wrapper for Http's IRouter
const router = plugins.licensing.createRouter(
plugins.metrics.createRouter(core.http.createRouter()
);
}
We just need to make sure that each plugins that extends the router, export an interface that defines what needs to be provided to each path definition. I would suggest having the configuration inside the namespace of the plugin id to avoid naming collision.
import { IRouter, CoreRouterPathConfig } from 'src/core/server';
import { LicenseRouterPathConfig } from '../licensing';
...
const router: RouterI<CoreRouterPathConfig & LicenseRouterPathConfig> = plugins.licensing.createRouter(core.http.createRouter());
licenseRouter.get(
{
path: '/my-route',
validate: /** schema */,
// The types for this wrapper could require a license level in the route config
{
licensing: { // namespace to avoid collision
licenseRequired: 'gold'
}
}
},
async (context, req, res) => { /** normal route body */ }
)
Or you could even have an option to use the same default license level for all routes in the plugin, and just override the level for some key routes.
We are now trying to find a solution for "license". In the past, you had to find a solution for "security", maybe tomorrow we need a solution to intercept "metrics" and do something with them. Giving flexibility to the consumer to intercept the request and do something with it seems like the most flexible approach.
That sounds like re-inventing the middlewares. Would adding a router and a route handler middlewares solve this problem in a more composable way? The only restriction that I'd like to unforce: middlewares cannot share the state. If a plugin needs it, it can implement this with private fields emulation via WeakMap.
const router = createRouter({middlewares: [withLicense('gold')]});
router.get(....{middlewares: [metrics]}, (context, req, res) => ..)
That sounds like re-inventing the middlewares.
We don't have to re-invent them, just allow them in our router 😊 I am trying to understand why we can't have them and why we don't use a known pattern with .pre( ... )
method instead of inventing our own. But I think I miss some background on pitfalls we want to get away from.
middlewares cannot share the state
Are we trying to avoid consumers to override/extend the context / req / res
objects?
But I think I miss some background on pitfalls we want to get away from.
I'm not sure we've explicitly written this down anywhere, so I'm going just going to braindump my opinions against middleware:
pre
, onRequest
, onPreValidation
, onResponse
, etc.) You can see how this happened in the Hapi.js framework really quickly. There's something like 10+ different middleware points in Hapi and I always have to refer to the docs to understand each one. With composition, we don't have to keep adding these things to change order, and the ordering is very explicit and easy to understand. Each plugin/route can wrap a route in the exact order of middlewares that they need without requiring any changes to other parts of Kibana. Thanks for detailing your thoughts @joshdover. Let's forget about the word middleware
and think of route guards
. It's just naming, but maybe middleware has a negative perception 😊
Introduces another way to accomplish something that is already possible today.
We currently can't declare a guard against /index_managemenet/*
API routes.
This means that we have to manually add the license check on 25 routes in index management. This also means that we can forget to add the check on a future route. The current solution does not allow us to protect our app as a whole. This is the problem I rose and I proposed the middelware (or guards) path.
So the current solution is wrapping handlers for all routes we have, like this:
export function registerRoute({ router, license, someOtherPlugin, lib }: RouteDependencies) {
router.post(
{ path: addBasePath('/indices/open'), validate: { body: bodySchema } },
someOtherPlugin.doSomething(
license.guardApiRoute(async (ctx, req, res) => {
const body = req.body;
// ...
})
)
);
}
So yes it is clear, on each route, what is going on. But this seems very verbose to me and we can easily forget to add the license check.
A Middleware that requires new route config options cannot be typed enforced automatically. Composition does not have this problem.
I would not go that path either, route guards are just that, route guards. In the above example license.guardApiRoute
does not require any additional configuration to the route.
Poor debuggability. Because middleware would be registered on the router and executed in a different call stack from the actual route handler
I hear you. Although if the stack traces show me the error under license.guardApiRoute
I think I would know where the error comes from.
Much less explicit. As a developer, I much prefer to have all the information about the context I'm programming in as possible.
I hear you, I guess this is the verbosity and error-prone tradeoff I mentioned.
Middleware ordering can become a problem.
If we limit the discussion to route "guards" then there is no problem. It is just reordering the guards in the array. So it would be router.guard({ path: '/some-path/'}, [])
with no notion of request method
as I initially added in my example.
In axios
terms, what I suggest would be kind of like declaring interceptors on the request (https://github.com/axios/axios#interceptors).
I think you got my points and I got yours. I let you decide what's best for Kibana 😊
@elastic/kibana-core https://github.com/elastic/kibana/pull/95973 adds the license_api_guard
plugin, which seems to address this issue. WDYT? If so, perhaps we can close this or open a separate issue to consider merging it into the license
plugin.
@cjcenizal Contributions are always welcome and I see no reason we can't include some version of this in the licensing plugins directly. @mshustov since you reviewed it in detail already, are there any significant changes you think we should make before moving this into our core licensing plugin?
@joshdover yes, there are a few:
minimum required license level
on a route-base. https://github.com/elastic/kibana/blob/681bd642fb54396b2ee27c982b8dc128e98bfb02/x-pack/plugins/license_api_guard/server/license.ts#L46License
class https://github.com/elastic/kibana/blob/681bd642fb54396b2ee27c982b8dc128e98bfb02/x-pack/plugins/license_api_guard/server/license.ts#L33 contains a lot of ES-specific
information. We are interested in guardApiRoute
solely.This looks like the license API guard approach solved this issue already, I will go ahead and close it
As part of his migration of the Index Management plugin, @sebelga has created a License class that we configure and use within every API route. It seems like it'd be convenient if we could just configure a license level for each route definition, or even better define it for the base path of the API itself so it applies to all routes built off of it.
Other benefits:
In a brief conversation with @joshdover, we came up with a few different ideas on how this could be implemented:
http
service that provides this option.IRouter
with