diff --git a/.betterer.results b/.betterer.results index fb78b68f6f9..5ea78d93fbe 100644 --- a/.betterer.results +++ b/.betterer.results @@ -28,6 +28,9 @@ exports[`better eslint`] = { "packages/grafana-alerting/src/grafana/notificationPolicies/utils.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], + "packages/grafana-data/src/context/plugins/RestrictedGrafanaApis.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "packages/grafana-data/src/dataframe/ArrayDataFrame.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], diff --git a/conf/defaults.ini b/conf/defaults.ini index 3908ddf4ad4..2bc06325d6c 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -2203,3 +2203,15 @@ fail_tests_on_console = true # Whether to enable betterer eslint rules for local development # Useful if you want to always see betterer rules that we're trying to fix so they're more prevalent betterer_eslint_rules = false + +#################################### Plugin API Restrictions ########################################## +# Configure which plugins can access specific restricted APIs. +# Use plugin IDs or regex patterns. Allow list takes precedence over block list. + +[plugins.restricted_apis_allowlist] +# Example: Allow specific plugins to access an API +# addPanel = "myorg-admin-app, grafana-enterprise-.*" + +[plugins.restricted_apis_blocklist] +# Example: Block specific plugins from accessing an API +# addPanel = "untrusted-.*, experimental-.*" diff --git a/packages/grafana-data/src/context/plugins/RestrictedGrafanaApis.test.tsx b/packages/grafana-data/src/context/plugins/RestrictedGrafanaApis.test.tsx new file mode 100644 index 00000000000..5ba64517caf --- /dev/null +++ b/packages/grafana-data/src/context/plugins/RestrictedGrafanaApis.test.tsx @@ -0,0 +1,197 @@ +import { renderHook, RenderHookResult } from '@testing-library/react'; + +import { + RestrictedGrafanaApisContextProvider, + RestrictedGrafanaApisContextType, + useRestrictedGrafanaApis, +} from './RestrictedGrafanaApis'; + +describe('RestrictedGrafanaApis', () => { + const apis: RestrictedGrafanaApisContextType = { + addPanel: () => {}, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should share an API if the plugin is allowed', () => { + const { result } = renderHook(() => useRestrictedGrafanaApis(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); + + // @ts-expect-error No APIs are defined yet + expect(result.current.addPanel).toEqual(apis.addPanel); + expect(Object.keys(result.current)).toEqual(['addPanel']); + }); + + it('should share an API if the plugin is allowed using a regexp', () => { + const { result } = renderHook(() => useRestrictedGrafanaApis(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); + + // @ts-expect-error No APIs are defined yet + expect(result.current.addPanel).toEqual(apis.addPanel); + expect(Object.keys(result.current)).toEqual(['addPanel']); + }); + + it('should not share an API if the plugin is not directly allowed and no allow regexp matches it', () => { + const { result } = renderHook(() => useRestrictedGrafanaApis(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); + + // @ts-expect-error No APIs are defined yet + expect(result.current.addPanel).not.toBeDefined(); + }); + + // Ideally the `allowList` and the `blockList` are not used together + it('should share an API if the plugin is both allowed and blocked (allow-list takes precendence)', () => { + const { result } = renderHook(() => useRestrictedGrafanaApis(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); + + // @ts-expect-error No APIs are defined yet + expect(result.current.addPanel).toEqual(apis.addPanel); + expect(Object.keys(result.current)).toEqual(['addPanel']); + }); + + it('should share an API with allowed plugins (testing multiple plugins)', () => { + let result: RenderHookResult; + + // 1. First app + result = renderHook(() => useRestrictedGrafanaApis(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); + // @ts-expect-error No APIs are defined yet + expect(result.result.current.addPanel).toEqual(apis.addPanel); + + // 2. Second app + result = renderHook(() => useRestrictedGrafanaApis(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); + // @ts-expect-error No APIs are defined yet + expect(result.result.current.addPanel).toEqual(apis.addPanel); + }); + + it('should not share APIs with plugins that are not allowed', () => { + const { result } = renderHook(() => useRestrictedGrafanaApis(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); + + // @ts-expect-error No APIs are defined yet + expect(result.current.addPanel).not.toBeDefined(); + }); + + it('should not share APIs with anyone if both the allowList and the blockList are empty', () => { + let result: RenderHookResult; + + result = renderHook(() => useRestrictedGrafanaApis(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); + // @ts-expect-error No APIs are defined yet + expect(result.result.current.addPanel).not.toBeDefined(); + + result = renderHook(() => useRestrictedGrafanaApis(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); + // @ts-expect-error No APIs are defined yet + expect(result.result.current.addPanel).not.toBeDefined(); + }); + + it('should not share APIs with blocked plugins', () => { + const { result } = renderHook(() => useRestrictedGrafanaApis(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); + // @ts-expect-error No APIs are defined yet + expect(result.current.addPanel).not.toBeDefined(); + }); + + it('should not share APIs with plugins that match any block list regexes', () => { + const { result } = renderHook(() => useRestrictedGrafanaApis(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); + // @ts-expect-error No APIs are defined yet + expect(result.current.addPanel).not.toBeDefined(); + }); +}); diff --git a/packages/grafana-data/src/context/plugins/RestrictedGrafanaApis.tsx b/packages/grafana-data/src/context/plugins/RestrictedGrafanaApis.tsx new file mode 100644 index 00000000000..7347db86545 --- /dev/null +++ b/packages/grafana-data/src/context/plugins/RestrictedGrafanaApis.tsx @@ -0,0 +1,79 @@ +import { createContext, ReactElement, PropsWithChildren, useMemo, useContext } from 'react'; + +export interface RestrictedGrafanaApisContextTypeInternal { + // Add types for restricted Grafana APIs here + // (Make sure that they are typed as optional properties) + // e.g. addPanel?: (vizPanel: VizPanel) => void; +} + +// We are exposing this through a "type validation", to make sure that all APIs are optional (which helps plugins catering for scenarios when they are not available). +type RequireAllPropertiesOptional = keyof T extends never + ? T + : { [K in keyof T]-?: undefined extends T[K] ? never : K }[keyof T] extends never + ? T + : 'Error: all properties of `RestrictedGrafanaApisContextTypeInternal` must be marked as optional, as their availability is controlled via a configuration parameter. Please have a look at `RestrictedGrafanaApisContextTypeInternal`.'; +export type RestrictedGrafanaApisContextType = RequireAllPropertiesOptional; + +// A type for allowing / blocking plugins for a given API +export type RestrictedGrafanaApisAllowList = Partial< + Record> +>; + +export const RestrictedGrafanaApisContext = createContext({}); + +export type Props = { + pluginId: string; + apis: RestrictedGrafanaApisContextType; + // Use it to share APIs with plugins (TAKES PRECEDENCE over `apiBlockList`) + apiAllowList?: RestrictedGrafanaApisAllowList; + // Use it to disable sharing APIs with plugins. + apiBlockList?: RestrictedGrafanaApisAllowList; +}; + +export function RestrictedGrafanaApisContextProvider(props: PropsWithChildren): ReactElement { + const { children, pluginId, apis, apiAllowList, apiBlockList } = props; + const allowedApis = useMemo(() => { + const allowedApis: RestrictedGrafanaApisContextType = {}; + + for (const api of Object.keys(apis) as Array) { + if ( + apiAllowList && + apiAllowList[api] && + (apiAllowList[api].includes(pluginId) || + apiAllowList[api].some((keyword) => keyword instanceof RegExp && keyword.test(pluginId))) + ) { + allowedApis[api] = apis[api]; + continue; + } + + // IF no allow list is defined (only block list), then we only omit the blocked APIs + if ( + (!apiAllowList || Object.keys(apiAllowList).length === 0) && + apiBlockList && + apiBlockList[api] && + !( + apiBlockList[api].includes(pluginId) || + apiBlockList[api].some((keyword) => keyword instanceof RegExp && keyword.test(pluginId)) + ) + ) { + allowedApis[api] = apis[api]; + } + } + + return allowedApis; + }, [apis, apiAllowList, apiBlockList, pluginId]); + + return {children}; +} + +export function useRestrictedGrafanaApis(): RestrictedGrafanaApisContextType { + const context = useContext(RestrictedGrafanaApisContext); + + if (!context) { + throw new Error( + 'useRestrictedGrafanaApis() can only be used inside a plugin context (The `RestrictedGrafanaApisContext` is not available).' + ); + } + + return context; +} diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index d1d302275d1..edb20df0082 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -439,6 +439,13 @@ export { type DataSourcePluginContextType, PluginContext, } from './context/plugins/PluginContext'; +export { + type RestrictedGrafanaApisContextType, + type RestrictedGrafanaApisAllowList, + RestrictedGrafanaApisContext, + RestrictedGrafanaApisContextProvider, + useRestrictedGrafanaApis, +} from './context/plugins/RestrictedGrafanaApis'; export { type PluginContextProviderProps, PluginContextProvider } from './context/plugins/PluginContextProvider'; export { type DataSourcePluginContextProviderProps, diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index 87136be6e1c..485b1ec4294 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -311,6 +311,8 @@ export interface GrafanaConfig { exploreDefaultTimeOffset: string; exploreHideLogsDownload: boolean; quickRanges?: TimeOption[]; + pluginRestrictedAPIsAllowList?: Record; + pluginRestrictedAPIsBlockList?: Record; // The namespace to use for kubernetes apiserver requests namespace: string; diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 85e9c6c1255..cc50e705d4b 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1076,10 +1076,14 @@ export interface FeatureToggles { dashboardLevelTimeMacros?: boolean; /** * Starts Grafana in remote secondary mode pulling the latest state from the remote Alertmanager to avoid duplicate notifications. - * @default false */ alertmanagerRemoteSecondaryWithRemoteState?: boolean; /** + * Enables sharing a list of APIs with a list of plugins + * @default false + */ + restrictedPluginApis?: boolean; + /** * Enable adhoc filter buttons in visualization tooltips */ adhocFiltersInTooltips?: boolean; diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index ec6e967cb7a..2576de932b2 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -242,6 +242,8 @@ export class GrafanaBootConfig { exploreDefaultTimeOffset = '1h'; exploreHideLogsDownload?: boolean; quickRanges?: TimeOption[]; + pluginRestrictedAPIsAllowList?: Record; + pluginRestrictedAPIsBlockList?: Record; /** * Language used in Grafana's UI. This is after the user's preference (or deteceted locale) is resolved to one of diff --git a/pkg/api/dtos/frontend_settings.go b/pkg/api/dtos/frontend_settings.go index 601654dc566..dd1313de717 100644 --- a/pkg/api/dtos/frontend_settings.go +++ b/pkg/api/dtos/frontend_settings.go @@ -207,25 +207,27 @@ type FrontendSettingsDTO struct { DashboardPerformanceMetrics []string `json:"dashboardPerformanceMetrics"` PanelSeriesLimit int `json:"panelSeriesLimit"` - FeedbackLinksEnabled bool `json:"feedbackLinksEnabled"` - ApplicationInsightsConnectionString string `json:"applicationInsightsConnectionString"` - ApplicationInsightsEndpointUrl string `json:"applicationInsightsEndpointUrl"` - DisableLoginForm bool `json:"disableLoginForm"` - DisableUserSignUp bool `json:"disableUserSignUp"` - LoginHint string `json:"loginHint"` - PasswordHint string `json:"passwordHint"` - ExternalUserMngInfo string `json:"externalUserMngInfo"` - ExternalUserMngLinkUrl string `json:"externalUserMngLinkUrl"` - ExternalUserMngLinkName string `json:"externalUserMngLinkName"` - ExternalUserMngAnalytics bool `json:"externalUserMngAnalytics"` - ExternalUserMngAnalyticsParams string `json:"externalUserMngAnalyticsParams"` - ViewersCanEdit bool `json:"viewersCanEdit"` - DisableSanitizeHtml bool `json:"disableSanitizeHtml"` - TrustedTypesDefaultPolicyEnabled bool `json:"trustedTypesDefaultPolicyEnabled"` - CSPReportOnlyEnabled bool `json:"cspReportOnlyEnabled"` - EnableFrontendSandboxForPlugins []string `json:"enableFrontendSandboxForPlugins"` - ExploreDefaultTimeOffset string `json:"exploreDefaultTimeOffset"` - ExploreHideLogsDownload bool `json:"exploreHideLogsDownload"` + FeedbackLinksEnabled bool `json:"feedbackLinksEnabled"` + ApplicationInsightsConnectionString string `json:"applicationInsightsConnectionString"` + ApplicationInsightsEndpointUrl string `json:"applicationInsightsEndpointUrl"` + DisableLoginForm bool `json:"disableLoginForm"` + DisableUserSignUp bool `json:"disableUserSignUp"` + LoginHint string `json:"loginHint"` + PasswordHint string `json:"passwordHint"` + ExternalUserMngInfo string `json:"externalUserMngInfo"` + ExternalUserMngLinkUrl string `json:"externalUserMngLinkUrl"` + ExternalUserMngLinkName string `json:"externalUserMngLinkName"` + ExternalUserMngAnalytics bool `json:"externalUserMngAnalytics"` + ExternalUserMngAnalyticsParams string `json:"externalUserMngAnalyticsParams"` + ViewersCanEdit bool `json:"viewersCanEdit"` + DisableSanitizeHtml bool `json:"disableSanitizeHtml"` + TrustedTypesDefaultPolicyEnabled bool `json:"trustedTypesDefaultPolicyEnabled"` + CSPReportOnlyEnabled bool `json:"cspReportOnlyEnabled"` + EnableFrontendSandboxForPlugins []string `json:"enableFrontendSandboxForPlugins"` + PluginRestrictedAPIsAllowList map[string][]string `json:"pluginRestrictedAPIsAllowList"` + PluginRestrictedAPIsBlockList map[string][]string `json:"pluginRestrictedAPIsBlockList"` + ExploreDefaultTimeOffset string `json:"exploreDefaultTimeOffset"` + ExploreHideLogsDownload bool `json:"exploreHideLogsDownload"` Auth FrontendSettingsAuthDTO `json:"auth"` diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index b9fff7d46fd..c7ee1818700 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -250,6 +250,8 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro QuickRanges: hs.Cfg.QuickRanges, SecureSocksDSProxyEnabled: hs.Cfg.SecureSocksDSProxy.Enabled && hs.Cfg.SecureSocksDSProxy.ShowUI, EnableFrontendSandboxForPlugins: hs.Cfg.EnableFrontendSandboxForPlugins, + PluginRestrictedAPIsAllowList: hs.Cfg.PluginRestrictedAPIsAllowList, + PluginRestrictedAPIsBlockList: hs.Cfg.PluginRestrictedAPIsBlockList, PublicDashboardAccessToken: c.PublicDashboardAccessToken, PublicDashboardsEnabled: hs.Cfg.PublicDashboardsEnabled, CloudMigrationIsTarget: isCloudMigrationTarget, diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index bbad4d1c9ba..4f2911a3069 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1869,6 +1869,15 @@ var ( Owner: grafanaAlertingSquad, HideFromAdminPage: true, HideFromDocs: true, + }, + { + Name: "restrictedPluginApis", + Description: "Enables sharing a list of APIs with a list of plugins", + Stage: FeatureStageExperimental, + Owner: grafanaPluginsPlatformSquad, + HideFromAdminPage: true, + HideFromDocs: true, + FrontendOnly: true, Expression: "false", }, { diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index c770f0fcc36..8895ebcc190 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -241,6 +241,7 @@ unifiedStorageSearchDualReaderEnabled,experimental,@grafana/search-and-storage,f dashboardDsAdHocFiltering,experimental,@grafana/datapro,false,false,true dashboardLevelTimeMacros,experimental,@grafana/dashboards-squad,false,false,true alertmanagerRemoteSecondaryWithRemoteState,experimental,@grafana/alerting-squad,false,false,false +restrictedPluginApis,experimental,@grafana/plugins-platform-backend,false,false,true adhocFiltersInTooltips,experimental,@grafana/datapro,false,false,true favoriteDatasources,experimental,@grafana/plugins-platform-backend,false,false,true newLogContext,experimental,@grafana/observability-logs,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index e1c9df82f22..13041b4714c 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -975,6 +975,10 @@ const ( // Starts Grafana in remote secondary mode pulling the latest state from the remote Alertmanager to avoid duplicate notifications. FlagAlertmanagerRemoteSecondaryWithRemoteState = "alertmanagerRemoteSecondaryWithRemoteState" + // FlagRestrictedPluginApis + // Enables sharing a list of APIs with a list of plugins + FlagRestrictedPluginApis = "restrictedPluginApis" + // FlagAdhocFiltersInTooltips // Enable adhoc filter buttons in visualization tooltips FlagAdhocFiltersInTooltips = "adhocFiltersInTooltips" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 709841d5f36..a7dc6c26854 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -572,16 +572,18 @@ { "metadata": { "name": "alertmanagerRemoteSecondaryWithRemoteState", - "resourceVersion": "1753448760331", - "creationTimestamp": "2025-07-25T13:06:00Z" + "resourceVersion": "1753776005753", + "creationTimestamp": "2025-07-25T13:06:00Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-07-29 08:00:05.753498 +0000 UTC" + } }, "spec": { "description": "Starts Grafana in remote secondary mode pulling the latest state from the remote Alertmanager to avoid duplicate notifications.", "stage": "experimental", "codeowner": "@grafana/alerting-squad", "hideFromAdminPage": true, - "hideFromDocs": true, - "expression": "false" + "hideFromDocs": true } }, { @@ -2889,6 +2891,25 @@ "expression": "false" } }, + { + "metadata": { + "name": "restrictedPluginApis", + "resourceVersion": "1753776783657", + "creationTimestamp": "2025-07-25T07:46:26Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-07-29 08:13:03.657209 +0000 UTC" + } + }, + "spec": { + "description": "Enables sharing a list of APIs with a list of plugins", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend", + "frontend": true, + "hideFromAdminPage": true, + "hideFromDocs": true, + "expression": "false" + } + }, { "metadata": { "name": "rolePickerDrawer", diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index a475dfd2a14..6c384c42fac 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -214,6 +214,10 @@ type Cfg struct { PluginUpdateStrategy string + // Plugin API restrictions - maps API name to list of plugin IDs/patterns + PluginRestrictedAPIsAllowList map[string][]string + PluginRestrictedAPIsBlockList map[string][]string + // Panels DisableSanitizeHtml bool @@ -1057,6 +1061,10 @@ func NewCfg() *Cfg { Raw: ini.Empty(), Azure: &azsettings.AzureSettings{}, + // Initialize plugin API restriction maps + PluginRestrictedAPIsAllowList: make(map[string][]string), + PluginRestrictedAPIsBlockList: make(map[string][]string), + // Avoid nil pointer IsFeatureToggleEnabled: func(_ string) bool { return false diff --git a/pkg/setting/setting_plugins.go b/pkg/setting/setting_plugins.go index 3739e253cfc..160e17169b7 100644 --- a/pkg/setting/setting_plugins.go +++ b/pkg/setting/setting_plugins.go @@ -110,6 +110,26 @@ func (cfg *Cfg) processPreinstallPlugins(rawInstallPlugins []string, preinstallP } } +// readPluginAPIRestrictionsSection reads a plugin API restrictions section and returns a map of API names to plugin lists +func readPluginAPIRestrictionsSection(iniFile *ini.File, sectionName string) map[string][]string { + result := make(map[string][]string) + + if !iniFile.HasSection(sectionName) { + return result + } + + section := iniFile.Section(sectionName) + for _, key := range section.Keys() { + apiName := key.Name() + pluginList := util.SplitString(key.MustString("")) + if len(pluginList) > 0 { + result[apiName] = pluginList + } + } + + return result +} + func (cfg *Cfg) readPluginSettings(iniFile *ini.File) error { pluginsSection := iniFile.Section("plugins") @@ -179,5 +199,9 @@ func (cfg *Cfg) readPluginSettings(iniFile *ini.File) error { cfg.PluginUpdateStrategy = pluginsSection.Key("update_strategy").In(PluginUpdateStrategyLatest, []string{PluginUpdateStrategyLatest, PluginUpdateStrategyMinor}) + // Plugin API restrictions - read from sections + cfg.PluginRestrictedAPIsAllowList = readPluginAPIRestrictionsSection(iniFile, "plugins.restricted_apis_allowlist") + cfg.PluginRestrictedAPIsBlockList = readPluginAPIRestrictionsSection(iniFile, "plugins.restricted_apis_blocklist") + return nil } diff --git a/public/app/features/plugins/components/AppRootPage.tsx b/public/app/features/plugins/components/AppRootPage.tsx index 7210f0ff471..ad94e6ea460 100644 --- a/public/app/features/plugins/components/AppRootPage.tsx +++ b/public/app/features/plugins/components/AppRootPage.tsx @@ -38,6 +38,7 @@ import { buildPluginSectionNav, pluginsLogger } from '../utils'; import { PluginErrorBoundary } from './PluginErrorBoundary'; import { buildPluginPageContext, PluginPageContext } from './PluginPageContext'; +import { RestrictedGrafanaApisProvider } from './restrictedGrafanaApis/RestrictedGrafanaApisProvider'; interface Props { // The ID of the plugin we would like to load and display @@ -116,22 +117,24 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) { /> )} > - - - + + + + + ); diff --git a/public/app/features/plugins/components/restrictedGrafanaApis/README.md b/public/app/features/plugins/components/restrictedGrafanaApis/README.md new file mode 100644 index 00000000000..5f95291318d --- /dev/null +++ b/public/app/features/plugins/components/restrictedGrafanaApis/README.md @@ -0,0 +1,48 @@ +# Restricted Grafana APIs + +The APIs available here are used to be only shared with certain plugins using the `RestrictedGrafanaApisContextProvider`. + +### FAQ + +**When should I use it to expose an API?** +If you only would like to share functionality with certain plugin IDs. + +**How to add an API to the list?** + +1. Add the API to a separate file under `public/app/features/plugins/components/restrictedGrafanaApis/` +2. Reference the API in the `restrictedGrafanaApis` variable in `public/app/features/plugins/components/restrictedGrafanaApis/RestrictedGrafanaApisProvider.tsx` +3. Update the `RestrictedGrafanaApisContextType` type under `packages/grafana-data/src/context/plugins/RestrictedGrafanaApis.tsx` + +**How to share an API with plugins?** +Enabling plugins is done via the Grafana config (config.ini). + +**Enabling APIs for a plugin** + +```ini +[plugins.restricted_apis_allowlist] +# This will share the `addPanel` api with app plugins that either have an id of "myorg-test-app" +addPanel = "myorg-test-app +``` + +**Disabling APIs for a plugin** + +```ini +[plugins.restricted_apis_blocklist] +# This is not sharing the `addPanel` api with app plugins that either have an id of "myorg-test-app" +addPanel = "myorg-test-app" +``` + +**How to use restricted APIs in a plugin?** +You should be access the restricted APIs in your plugin using the `useRestrictedGrafanaApis()` hook: + +```ts +import { RestrictedGrafanaApisContextType, useRestrictedGrafanaApis } from "@grafana/data"; + +// Inside a component +const { addPanel } = useRestrictedGrafanaApis(); + +// Make sure you cater for scenarios where the API is not available +if (addPanel) { + addPanel({ ... }); +} +``` diff --git a/public/app/features/plugins/components/restrictedGrafanaApis/RestrictedGrafanaApisProvider.tsx b/public/app/features/plugins/components/restrictedGrafanaApis/RestrictedGrafanaApisProvider.tsx new file mode 100644 index 00000000000..3ecbd8f95c0 --- /dev/null +++ b/public/app/features/plugins/components/restrictedGrafanaApis/RestrictedGrafanaApisProvider.tsx @@ -0,0 +1,29 @@ +import { PropsWithChildren, ReactElement } from 'react'; + +import { RestrictedGrafanaApisContextProvider, RestrictedGrafanaApisContextType } from '@grafana/data'; +import { config } from '@grafana/runtime'; + +const restrictedGrafanaApis: RestrictedGrafanaApisContextType = config.featureToggles.restrictedPluginApis + ? { + // Add your restricted APIs here + // (APIs that should be availble to ALL plugins should be shared via our packages, e.g. @grafana/data.) + } + : {}; + +// This Provider is a wrapper around `RestrictedGrafanaApisContextProvider` from `@grafana/data`. +// The reason for this is that like this we only need to define the configuration once (here) and can use it in multiple places (app root page, extensions). +export function RestrictedGrafanaApisProvider({ + children, + pluginId, +}: PropsWithChildren<{ pluginId: string }>): ReactElement { + return ( + + {children} + + ); +} diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index dc195f08888..5d5498ce0ac 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -22,6 +22,8 @@ import appEvents from 'app/core/app_events'; import { getPluginSettings } from 'app/features/plugins/pluginSettings'; import { CloseExtensionSidebarEvent, OpenExtensionSidebarEvent, ShowModalReactEvent } from 'app/types/events'; +import { RestrictedGrafanaApisProvider } from '../components/restrictedGrafanaApis/RestrictedGrafanaApisProvider'; + import { ExtensionErrorBoundary } from './ExtensionErrorBoundary'; import { ExtensionsLog, log as baseLog } from './logs/log'; import { AddedLinkRegistryItem } from './registry/AddedLinksRegistry'; @@ -98,9 +100,11 @@ export const wrapWithPluginContext = ({ return ( - + + + );