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

Integrate Licensing & Feature Controls with ApplicationService #45291

Closed
joshdover opened this issue Sep 10, 2019 · 24 comments · Fixed by #50223
Closed

Integrate Licensing & Feature Controls with ApplicationService #45291

joshdover opened this issue Sep 10, 2019 · 24 comments · Fixed by #50223
Assignees
Labels
blocker discuss Feature:New Platform Team:Core Core services & architecture: plugins, logging, config, saved objects, http, ES client, i18n, etc

Comments

@joshdover
Copy link
Contributor

joshdover commented Sep 10, 2019

There are 3 different cases when an application should be disabled or hidden from the UI:

  • The user does not have the privileges via Feature Controls (aka uiCapabilities)
  • The Elasticsearch cluster does not have the appropriate license
  • The Kibana configuration has disabled the UI for a given application, but not the entire plugin (eg. timelion.ui.enabled).

In the Legacy Platform we have a few different mechanisms for controlling which apps are shown & allowed.

  • Some apps are disabled using a uiExport hack that calls chrome.navLinks.update() to disable a navlink based on some of the conditions listed above. Each app has to do this separately and the implementations are not consistent.
  • Feature controls filter out applications by toggling the appropriate navLink item in uiCapabilities.

The ApplicationService should expose a function that allows other systems to register an Observable of filtering functions that can be used to disable and hide applications. This would allow the Licensing and Feature Control plugins to extend the ApplicationService with this knowledge in a single place, rather than each plugin defining this logic.

Example API

type AppFilter = (app: App) => {
  visible: boolean;
  enabled: boolean;
}

interface ApplicationSetup {
  registerAppFilter(filter$: Observable<AppFilter>): void;
}
// Example usage inside the licensing plugin
core.application.registerAppFilter(
  license$.pipe(
    map(license => (app: App) => ({
      visible: license.get(`features.${app.id}.showAppLink`),
      enabled: license.get(`features.${app.id}.enableAppLink`),
    })
  )
)
@joshdover joshdover added Team:Core Core services & architecture: plugins, logging, config, saved objects, http, ES client, i18n, etc Feature:New Platform labels Sep 10, 2019
@elasticmachine
Copy link
Contributor

Pinging @elastic/kibana-platform

@joshdover joshdover added this to Prioritized Backlog in kibana-core [DEPRECATED] Sep 11, 2019
@pgayvallet pgayvallet self-assigned this Oct 29, 2019
@pgayvallet
Copy link
Contributor

The NP api currently have a strong 1-1 binding between App and NavLink, as aNavLink elements are directly generated from the new App properties.

In legacy, applications and navlinks are two distinct and dissociated concept, as plugins register them separately.

I lack the context of this decision, so I have to ask: what this something we already decided on ?

If I take the exemple of the legacy kibana plugin:

 uiExports: {
      [...]
      app: {
        id: 'kibana',
        title: 'Kibana',
        listed: false,
        main: 'plugins/kibana/kibana',
      },
      links: [
        {
          id: 'kibana:discover',
          title: i18n.translate('kbn.discoverTitle', {
            defaultMessage: 'Discover',
          }),
          order: -1003,
          url: `${kbnBaseUrl}#/discover`,
          euiIconType: 'discoverApp',
        },
        {
          id: 'kibana:visualize',
          title: i18n.translate('kbn.visualizeTitle', {
            defaultMessage: 'Visualize',
          }),
          order: -1002,
          url: `${kbnBaseUrl}#/visualize`,
          euiIconType: 'visualizeApp',
        },
        {
          id: 'kibana:dev_tools',
          title: i18n.translate('kbn.devToolsTitle', {
            defaultMessage: 'Dev Tools',
          }),
          order: 9001,
          url: '/app/kibana#/dev_tools',
          euiIconType: 'devToolsApp',
        },
        [...]
      ],

How would this be migrated to NP ? Will every link become an actual App in NP ?

@rudolf
Copy link
Contributor

rudolf commented Oct 30, 2019

How would this be migrated to NP ? Will every link become an actual App in NP ?

Yes. We will also add "standalone applications" #41981 these are registered apps that don't add a NavLink.

@pgayvallet pgayvallet moved this from Prioritized Backlog to In progress in kibana-core [DEPRECATED] Oct 30, 2019
@pgayvallet
Copy link
Contributor

So basically today, we have two kind of apps (apps, not plugins):

Regarding the normal applications, the ones that needs to be disabled for various reasons are doing it in two distinct ways:

  • Changing the navlink hidden property to true, therefor hiding it from the navigation
  • Changing the navlink disabled property to true, therefor keeping the navlink, but making it disabled and non-clickable.

The latest is currently only used in two plugins:

  • graph
  • ml

I was thinking about removing the capability to keep a disabled navlink, However, after speaking with @timroes and @flash1293 , the use case (at least for graph, but probably also for ml) is the following:

Graph has this logic for showing/disabling the graph app: x-pack/legacy/plugins/graph/server/lib/check_license.js
The gist is that the app is still shown in the sidebar but marked as disabled in case the user has a license including graph which is just expired. This makes sense because Graph isn't shown at all in the side bar for other license levels and the user might be confused why the app disappears if the license expires.
So it's about to avoid this scenario:
• User uses Graph and is happy
• License expires
• User logs in and the Graph app is missing from the menu, no other explanation
• User doesn't realize it's because of the expired license and thinks Kibana is broken.

So, basically this seems to be a way to disable the application but to keep the link to not disturb the user in case of expired license.

So we seems to have 3 possible states for the application:

  • enabled (default behaviour)
  • disabled (app is basically disabled, not accessible and without an icon in nav)
  • expired, which is the special case I talked about previously, with the app being disabled, but the icon present but non-clickable (just here for the tooltip message on it)

So I'm think about two options here for the filter:

1/

type AppFilter = (app: App) => 'enabled' | 'disabled' | 'expired';

or

2/

type AppState =  'enabled' | 'disabled' | 'expired';
type NavlinkState = 'visible' | 'hidden' | 'inactive'; // inactive to not misinterpret with 'disabled'
type AppFilter = (app: App) => AppState | {
  app:  AppState;
  navlink: NavlinkState
}

In that scenario, we allow the filter function to be able to either return

  • only an AppState status. in that case, the equivalent NavlinkState is a simple mapping (enabled->visible, disabled->hidden)
  • a structure containing both AppState and NavlinkState to have more finely grained configuration options

The second one seems more generic, however I'm not sure we want to manage some combination (app=disabled + navlink=visible for exemple) and I don't see any pro for it, so I'm leaning to solution 1/

The other question I have is regarding the navlink tooltip in case of expired app.

Currently in graph, this is what is done:

const navLinkUpdates = {};
navLinkUpdates.hidden = true;
const showAppLink = xpackInfo.get('features.graph.showAppLink', false);
navLinkUpdates.hidden = !showAppLink;
if (showAppLink) {
  navLinkUpdates.disabled = !xpackInfo.get('features.graph.enableAppLink', false);
  navLinkUpdates.tooltip = xpackInfo.get('features.graph.message');
}

npStart.core.chrome.navLinks.update('graph', navLinkUpdates);

When the app should be in the new expired state, there is also a need to change the tooltip for the disabled version of it. However, with current proposal, this would needs to be done as a separate operation using the AppBase.tooltip$ property.

Issue is, if we plan at some point to centralise the app disabling logic (tolicensing and/or security probably), with current API, they will not be able to change the tooltip when setting an application state to expired, and I'm not sure how to to address that.

  • Maybe we should go back on the observable tooltip (which is currently not working anyway) and allow the AppFilter to return a tooltip ?
  • Or maybe (simpler, not sure if acceptable) simply display a generic tooltip when the application is expired, such as License for this (feature/app/plugin?) is expired, [...] ?

WDYT ?

@joshdover
Copy link
Contributor Author

joshdover commented Oct 30, 2019

Option 1/ makes sense to me, though maybe we should make the states more generic to fit better in the OSS use case. I don't think expired quite makes sense as it reflects our expected consumer rather than the behavior of the AppService. Maybe these should be the names of the states:

type AppFilter = (app: App) =>
  'accessible' |
  'inaccessibleWithoutNavlink' |
  'inaccessibleWithDisabledNavlink';

With standalone/hidden apps just ignore AppFilters completely, correct? In other words, being a hidden app is an immutable property that overrides the AppFilter behavior.


  • Maybe we should go back on the observable tooltip (which is currently not working anyway) and allow the AppFilter to return a tooltip ?
  • Or maybe (simpler, not sure if acceptable) simply display a generic tooltip when the application is expired, such as License for this (feature/app/plugin?) is expired, [...] ?

I think this depends on the names of the states. If we go with expired then I think a generic tooltip would be fine, however if we use the names I suggested above, I don't think we can use a generic tooltip.

If we need to allow custom tooltips, maybe the name AppFilters isn't quite right since these functions are doing more than just offering a filter. Maybe instead we should be calling this concept "app status" or something similarly generic. We may find other similar use cases in the future that we could easily extend this API to support, without introducing breaking changes.

@pgayvallet
Copy link
Contributor

pgayvallet commented Oct 30, 2019

The OSS argument is a good point, however I'm not a big fan of over-long-and-complicated-name-when-not-absolute-necessity.

Maybe

type AppFilter = (app: App) =>
  'accessible' |
  'inaccessible' |
  'inaccessibleWithDisabledNavlink';

Is acceptable as it should be quite clear that the behaviour for inaccessible should be to hide the navigation links (and also properly documented) ?

With standalone/hidden apps just ignore AppFilters completely, correct? In other words, being a hidden app is an immutable property that overrides the AppFilter behavior.

For standalone apps I would say yes, as long as we are sure we will never want to be able to disable one ?

If we need to allow custom tooltips, maybe the name AppFilters isn't quite right since these functions are doing more than just offering a filter. Maybe instead we should be calling this concept "app status" or something similarly generic. We may find other similar use cases in the future that we could easily extend this API to support, without introducing breaking changes.

We are going closer to the existing ChromeNavLinkUpdateableFields by doing that, but I agree that's probably safer and more futur-proof as we don't have a precise vision of all the fields we want to support. Also updating the actual App instead of the associated NavLink is definitely what we want to do.

So something like type AppStatusUpdater = (App) => Partial<AppStatusFields> seems like a good approach, and it will be easy to add new fields to it if required later in time.

@pgayvallet
Copy link
Contributor

So now the current proposal is something like this:

type AppStatus = 'accessible' | 'inaccessible' | 'inaccessibleWithDisabledNavlink';

type AppStatusFields = Pick<AppBase, 'status' | 'tooltip'>

type AppStatusUpdater = (app: AppBase) => Partial<AppStatusFields> | undefined;

interface ApplicationSetup {
  [...]
  registerAppStatusUpdater(statusUpdater$: Observable<AppStatusUpdater>): void;
}

@joshdover
Copy link
Contributor Author

Also updating the actual App instead of the associated NavLink is definitely what we want to do.

Absolutely, because these options should not just affect the Chrome UI but also the ApplicationService router.

@joshdover
Copy link
Contributor Author

You may want to define these values as an integer enum, where the greater value (least permissive option) takes precedence over lower values. This is important since multiple AppStatusUpdaters may be registered at once.

enum AppStatus {
  accessible = 0,
  inaccessibleWithDisabledNavLink = 1,
  inaccessible = 2,
}

If one updater returns accessible and another returns inaccessibleWithDisabledNavLink, the greater of the two (inaccessibleWithDisabledNavLink) will be the final computed value.

@pgayvallet
Copy link
Contributor

You're faster than me, was writing something about the precedence! I agree.

@pgayvallet
Copy link
Contributor

Another question I was asking myself is about when registerAppStatusUpdater should be available. do we want it only during setup, or also for startup (technically we can add it to both).

The technical constraint I see here if only added in setup is that I'm not sure how this cohabitate with legacy.

I.E an existing call:

import { npStart } from 'ui/new_platform';
[...]
npStart.core.chrome.navLinks.update('graph', navLinkUpdates);

I see that npSetup is accessible the same way from 'ui/new_platform'. Is there no restriction to use it, like:

import { npSetup } from 'ui/new_platform';
[...]
npSetup.core.application. registerAppStatusUpdater([...])

Or is there some subtlety ? Not sure about the core lifecycle in legacy world ?

@joshdover
Copy link
Contributor Author

joshdover commented Oct 30, 2019

All legacy code gets executed after both setup and start have run in the New Platform services and plugins.

I think we should be able to keep this only on setup and consumers can use the bindServices pattern to get access to any start APIs they need from within their Observable code.

We will just need to make sure that registerAppStatusUpdater function can still be called after setup in order to support the legacy code.

@joshdover joshdover mentioned this issue Nov 3, 2019
3 tasks
@mshustov
Copy link
Contributor

interface ApplicationSetup {
  registerAppFilter(filter$: Observable<AppFilter>): void;
}
// Example usage inside the licensing plugin
core.application.registerAppFilter(
  license$.pipe(
    map(license => (app: App) => ({
      visible: license.get(`features.${app.id}.showAppLink`),
      enabled: license.get(`features.${app.id}.enableAppLink`),
    })
  )
)

There could be more sophisticated cases besides mapping:

showAppLink --> visible
enableAppLink --> enabled

When a plugin license result depends on another plugin/feature license status, for example. I don't think that the license plugin should contain this logic. Plugins should calculate own license status itself and provide this information to other plugins via their contract (when necessary).
Some examples:
https://github.com/restrry/kibana/blob/7fd6499138cc0eb0fad6fcacec388fb7a6cbff05/x-pack/legacy/plugins/reporting/server/lib/get_user.js#L15
https://github.com/restrry/kibana/blob/7fd6499138cc0eb0fad6fcacec388fb7a6cbff05/x-pack/legacy/plugins/beats_management/server/lib/adapters/framework/kibana_framework_adapter.ts#L175-L181
https://github.com/restrry/kibana/blob/7fd6499138cc0eb0fad6fcacec388fb7a6cbff05/x-pack/legacy/plugins/monitoring/server/cluster_alerts/check_license.js#L87

This would allow the Licensing and Feature Control plugins to extend the ApplicationService with this knowledge in a single place, rather than each plugin defining this logic.

I don't see a lot of benefits of scattering logic over a few places. Given a proposed syntax:

core.application.register({
  id: 'ml',
  metadata: {
    requiredLicenseLevel: ['trial', 'platinum']
  },
  mount( ... ) { },
})

Now application service allows attaching an arbitrary piece of data, which will be used by the license plugin later. It will be hard to track all the dependencies passed through this mechanism.

Maybe we should go back on the observable tooltip (which is currently not working anyway) and allow the AppFilter to return a tooltip

Just curious why not allow plugins to register a custom renderer for a navLink? We can get rid of icon, euiIconType, tooltip$ properties. On top of that, we give more powerful API for the plugins: they can render a notification balloon, status bar, etc.

We will just need to make sure that registerAppStatusUpdater function can still be called after setup in order to support the legacy code.

We can expose updater in start phase only for LP. Otherwise plugin authors will be confused about differences between setup and start phases.

We will also add "standalone applications" #41981 these are registered apps that don't add a NavLink.

We already use the standalone app, hidden app and #41981 coined chromeless app. Should we establish a common name for the concept? The common dictionary is important.

@joshdover
Copy link
Contributor Author

Plugins should calculate own license status itself and provide this information to other plugins via their contract (when necessary).

I agree, if there many varied calculations, than we should not do a global licensing integration and should instead just provide a mechanism for plugins to register their own filter/updater.

Just curious why not allow plugins to register a custom renderer for a navLink? We can get rid of icon, euiIconType, tooltip$ properties. On top of that, we give more powerful API for the plugins: they can render a notification balloon, status bar, etc.

I like this idea for the presentation side of things, but there's more to this than just what the icon looks like. The mechanism needed by licensing and feature controls is to actually show a 404-like page when a user tries to access an App via routing directly to it.

We already use the standalone app, hidden app and #41981 coined chromeless app. Should we establish a common name for the concept? The common dictionary is important.

Agreed, we need a standard name. In discussions with @eliperelman and @pgayvallet last week, we decided "chromeless" for these was the least ambiguous name. This replaces the "standalone" and "hidden" nomenclature.

@pgayvallet
Copy link
Contributor

pgayvallet commented Nov 12, 2019

Just curious why not allow plugins to register a custom renderer for a navLink? We can get rid of icon, euiIconType, tooltip$ properties. On top of that, we give more powerful API for the plugins: they can render a notification balloon, status bar, etc.

In my experience, allowing full rendering access to that kind of sensible UI parts always result in breaking some global stuff at some point. I'm not very fond of allowing plugin developers to freely render custom things into global-spaced UI (even if getting rid of vendor types such as euiIconType would indeed be very nice).

I agree, if there many varied calculations, than we should not do a global licensing integration and should instead just provide a mechanism for plugins to register their own filter/updater.

The proposed implementation actually allows to do that. Instead of having security or licensing registering a global app filter, we can just recommend that each plugins register their own filter for their applications. If we go (exclusively) in that direction however, we may want to add the $statusUpdater to the actual app definition instead of allowing to add them afterwise ?

const statusUpdaters$ = new BehaviorSubject<AppStatusUpdater>(() => ({}));

core.application.register({
  id: 'ml',
  statusUpdater$,
  mount( ... ) { },
})

// later

statusUpdaters$.next((app) => {
    return {
      status: 'whatever'
    }
})

or to add a initial id parameter to registerAppFilter

core.application.registerAppFilter('myApp',
  license$.pipe(
    map(license => (app: App) => ({ // app parameter may even be unnecessary in that case
      visible: license.get(`features.${app.id}.showAppLink`),
      enabled: license.get(`features.${app.id}.enableAppLink`),
    })
  )
)

( I think I prefer the first approach if we want to forget about delegating this responsibility to a specific plugin). One limitation is that we can't properly manage accessibility status for legacy apps that way (we can with the 'global' filters), but not sure if it's an issue

FYI, I have a draft PR with the initial proposed implementation: #50223 that can be a starting point for any wanted changes on the API

@joshdover
Copy link
Contributor Author

If we go (exclusively) in that direction however, we may want to add the $statusUpdater to the actual app definition instead of allowing to add them afterwise ?

I think makes sense in the licensing case, but maybe not in the feature controls case.

One limitation is that we can't properly manage accessibility status for legacy apps that way (we can with the 'global' filters), but not sure if it's an issue

I don't think we need to necessarily worry about legacy plugins, but only if we're ok leaving the legacy code for those apps there until they'e been migrated. I think in most cases this should be fine.

@pgayvallet
Copy link
Contributor

@joshdover @restrry
Got very mixed inputs here :) So what should we do? should we actually implement both approaches ?

A/

interface ApplicationSetup {
  [...]
  registerAppStatusUpdater(statusUpdater$: Observable<AppStatusUpdater>): void;
}

and

B/

core.application.register({
  id: 'ml',
  statusUpdater$,
  mount( ... ) { },
})

to answer both cases in the best way ?

I have no idea what we plan to do regarding NP feature controls. But the current usages of app filtering / disabling are closer in use to the B proposal.

@joshdover
Copy link
Contributor Author

@kobelb I'm fuzzy now on what we do to with feature controls to prevent access to applications entirely. I know we have logic to filter out navlinks based on feature controls, but where do we currently prevent a user from accessing /app/canvas if they do not have authz?

@kobelb
Copy link
Contributor

kobelb commented Nov 12, 2019

@pgayvallet
Copy link
Contributor

So atm, actual app authorisation check is only done server side. The only part that is done client-side is the 'hide from navbar using custom, non-generic per-navlink checks'.

Server side authorisation check however is generic, checking permission for every apps in a single place.

So, we're back to the ticket description:

The ApplicationService should expose a function that allows other systems to register an Observable of filtering functions that can be used to disable and hide applications. This would allow the Licensing and Feature Control plugins to extend the ApplicationService with this knowledge in a single place, rather than each plugin defining this logic.

If we want to allow this, the A/ proposal of my previous comment seems like the correct approach. If we only want to migrate what's done in LP to hide/disable an app, B/ seems like the way to go. If we want both, we can also implements both, and it wouldn't be much more work.

Main question now would be: what do we actually want 😄?

I have the feeling that in this specific case, more is probably better than less? Are we all alright to implement both approach? That way we can migrate existing code that hides navlinks using B/, and are still ready for the day we may want to implement (or let security implement) client-side app checks using A/?

@joshdover
Copy link
Contributor Author

tl;dr: I think we will need both, or something close to it.

The current server-side authz checks won't really work in the New Platform because we don't do a server-side render for each app like we do in legacy. Given that, there's not any global hooks for security to plug into to restrict access.

An alternative option could be to do A for license checks now, use the current capabilities filtering to hide app icons for feature controls, and then add a beforeMount hook API to the ApplicationService that can be used to port the server-side code @kobelb linked to above to the New Platform (but on the client).

@kobelb
Copy link
Contributor

kobelb commented Nov 13, 2019

beforeMount hook API to the ApplicationService that can be used to port the server-side code @kobelb linked to above to the New Platform (but on the client).

We'll likely have to rework a few things to make this possible, but it won't be a colossal amount of effort. If you can let us know when the hook is available, we can begin taking advantage of it.

@pgayvallet
Copy link
Contributor

So I guess I will implement both in #50223

  • ApplicationSetup#registerAppStatusUpdater to allow registering status updater for all applications. This may be replaced/enhanced at a later time with a beforeMount hook in the ApplicationService

  • adding a statusUpdater$ field in app registrations (core.application.register) to allow application to add/migrate their own status management. The field will be provided in AppBase and therefor also be available for legacy app registration.

Precedence of status is shared between them, meaning that the most restricting status will always be applied.

Is that alright with everyone?

@mshustov
Copy link
Contributor

@pgayvallet works for me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
blocker discuss Feature:New Platform Team:Core Core services & architecture: plugins, logging, config, saved objects, http, ES client, i18n, etc
Projects
None yet
Development

Successfully merging a pull request may close this issue.

6 participants