Skip to content
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

Build license-checking into the New Platform #56926

Closed
cjcenizal opened this issue Feb 5, 2020 · 15 comments
Closed

Build license-checking into the New Platform #56926

cjcenizal opened this issue Feb 5, 2020 · 15 comments
Labels
enhancement New value added to drive a business result Feature:License Feature:New Platform Team:Core Core services & architecture: plugins, logging, config, saved objects, http, ES client, i18n, etc

Comments

@cjcenizal
Copy link
Contributor

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:

  • This would also standardize the license check response, which would help make consuming code and UIs follow a common pattern.
  • If license-checking is baked into X-Pack routing, then it can enforce the requirement for a license (e.g. at the setup stage). If a plugin skips this step then we can emit errors and prevent the plugin from working. This is useful for devs because if a plugin is in X-Pack it must have a license, but it's easy to overlook this as a human.

In a brief conversation with @joshdover, we came up with a few different ideas on how this could be implemented:

  • A global hook for X-Pack to hook into
  • An X-Pack-only layer on top of Core's http service that provides this option.
  • A utility that developers can use to wrap IRouter with
  • Middleware that gets registered in the router (an idea @sebelga mentioned to me)
@cjcenizal cjcenizal added Team:Core Core services & architecture: plugins, logging, config, saved objects, http, ES client, i18n, etc Feature:New Platform enhancement New value added to drive a business result Feature:License labels Feb 5, 2020
@elasticmachine
Copy link
Contributor

Pinging @elastic/kibana-platform (Team:Platform)

@sebelga
Copy link
Contributor

sebelga commented Feb 6, 2020

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]);

@joshdover
Copy link
Contributor

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:

  • No new features needed in Core. Keeping Core simple and robust is really important to keeping Kibana stable and healthy.
  • Requirements of the middleware can introduce new type constraints, such as requiring a config key on the route (eg. licenseRequired).
  • Simple to understand, and very explicit. Any route in the plugin that uses this wrapped router gets feedback right in the editor that this is a special kind of router, with special behavior.
  • Easy to test independent of Core.

@legrego
Copy link
Member

legrego commented Feb 6, 2020

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:

export const createLicensedRouteHandler = <P, Q, B>(handler: RequestHandler<P, Q, B>) => {
const licensedRouteHandler: RequestHandler<P, Q, B> = (context, request, responseToolkit) => {
const { license } = context.licensing;
const licenseCheck = license.check('security', 'basic');
if (
licenseCheck.state === LICENSE_CHECK_STATE.Unavailable ||
licenseCheck.state === LICENSE_CHECK_STATE.Invalid
) {
return responseToolkit.forbidden({ body: { message: licenseCheck.message! } });
}
return handler(context, request, responseToolkit);
};
return licensedRouteHandler;
};

@mshustov
Copy link
Contributor

mshustov commented Feb 7, 2020

@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.

@joshdover
Copy link
Contributor

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.

@sebelga
Copy link
Contributor

sebelga commented Feb 10, 2020

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 */ }
    )

@mshustov
Copy link
Contributor

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) => ..)

@sebelga
Copy link
Contributor

sebelga commented Feb 10, 2020

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?

@joshdover
Copy link
Contributor

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:

  • Introduces another way to accomplish something that is already possible today. In general, we're trying to steer the Kibana codebase to be internally consistent in its patterns. Having another way to do something increases the learning curve for new Kibana Developers.
  • Middleware that requires new route config options cannot be typed enforced automatically. Composition does not have this problem.
  • Poor debuggability. Because middleware would registered on the router and executed in a different call stack from the actual route handler, it's less obvious in stacktraces where something may be going wrong or behaving different than you expect. Using composition makes this more obvious to developers who are unaware of the middleware.
  • Much less explicit. As a developer, I much prefer to have all the information about the context I'm programming in as possible. Having middleware that is registered elsewhere affect the route I am programming is not obvious. However, using composition changes the type I'm programming against and can easily explain this behavior to me.
  • Middleware ordering can become a problem. If a specific route or subset of routes want to add a middleware at a different point in the middleware stack, then we have to start adding new middleware levels (eg. 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.

@sebelga
Copy link
Contributor

sebelga commented Feb 11, 2020

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 😊

@cjcenizal
Copy link
Contributor Author

@elastic/kibana-core #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.

@joshdover
Copy link
Contributor

@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?

@mshustov
Copy link
Contributor

@joshdover yes, there are a few:

  • guard should return a plugin-specific error message, instead of relying on a generic message provided by `licensing plugin
    'Your {licenseType} license does not support {pluginName}. Please upgrade your license.',
    values: { licenseType: this.licenseType!, pluginName: this.pluginName },
    });
    case 'expired':
    return i18n.translate('xpack.licenseApiGuard.license.errorExpiredMessage', {
    defaultMessage:
    'You cannot use {pluginName} because your {licenseType} license has expired.',
    values: { licenseType: this.licenseType!, pluginName: this.pluginName },
    });
    case 'unavailable':
    return i18n.translate('xpack.licenseApiGuard.license.errorUnavailableMessage', {
    defaultMessage:
    'You cannot use {pluginName} because license information is not available at this time.',
    values: { pluginName: this.pluginName },
    });
    }
    return i18n.translate('xpack.licenseApiGuard.license.genericErrorMessage', {
    defaultMessage: 'You cannot use {pluginName} because the license check failed.',
  • guard should allow plugins to specify minimum required license level on a route-base.
    start({ pluginId, minimumLicenseType, licensing }: StartSettings) {
  • License class contains a lot of ES-specific information. We are interested in guardApiRoute solely.

@pgayvallet
Copy link
Contributor

This looks like the license API guard approach solved this issue already, I will go ahead and close it

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New value added to drive a business result Feature:License Feature:New Platform Team:Core Core services & architecture: plugins, logging, config, saved objects, http, ES client, i18n, etc
Projects
Status: Done (7.13)
Development

No branches or pull requests

7 participants