mirror of https://github.com/grafana/grafana.git
Plugins: Expose core APIs only for certain plugins (#107967)
* feat(plugins): add a way to expose core apis only to certain plugins * review: update naming * review: update the owners of the feature toggle * feat: share the restricted apis with extensions * fix: linters * feat: remove the `addPanel` api * chore: fix linting and betterer issue * tests: use `@ts-expect-error` for more clarity
This commit is contained in:
parent
da43e2ae07
commit
d31e682345
|
@ -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"]
|
||||
],
|
||||
|
|
|
@ -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-.*"
|
||||
|
|
|
@ -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 }) => (
|
||||
<RestrictedGrafanaApisContextProvider
|
||||
apis={apis}
|
||||
pluginId={'grafana-test-app'}
|
||||
apiAllowList={{ addPanel: ['grafana-test-app'] }}
|
||||
>
|
||||
{children}
|
||||
</RestrictedGrafanaApisContextProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// @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 }) => (
|
||||
<RestrictedGrafanaApisContextProvider
|
||||
apis={apis}
|
||||
pluginId={'grafana-test-app'}
|
||||
apiAllowList={{ addPanel: [/^grafana-/] }}
|
||||
>
|
||||
{children}
|
||||
</RestrictedGrafanaApisContextProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// @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 }) => (
|
||||
<RestrictedGrafanaApisContextProvider
|
||||
apis={apis}
|
||||
pluginId={'myorg-test-app'}
|
||||
apiAllowList={{ addPanel: [/^grafana-/] }}
|
||||
>
|
||||
{children}
|
||||
</RestrictedGrafanaApisContextProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// @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 }) => (
|
||||
<RestrictedGrafanaApisContextProvider
|
||||
apis={apis}
|
||||
pluginId={'grafana-test-app'}
|
||||
apiAllowList={{ addPanel: ['grafana-test-app'] }}
|
||||
apiBlockList={{ addPanel: ['grafana-test-app'] }}
|
||||
>
|
||||
{children}
|
||||
</RestrictedGrafanaApisContextProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// @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<RestrictedGrafanaApisContextType, unknown>;
|
||||
|
||||
// 1. First app
|
||||
result = renderHook(() => useRestrictedGrafanaApis(), {
|
||||
wrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<RestrictedGrafanaApisContextProvider
|
||||
apis={apis}
|
||||
pluginId={'grafana-test-app'}
|
||||
apiAllowList={{ addPanel: ['grafana-test-app', 'grafana-assistant-app'] }}
|
||||
>
|
||||
{children}
|
||||
</RestrictedGrafanaApisContextProvider>
|
||||
),
|
||||
});
|
||||
// @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 }) => (
|
||||
<RestrictedGrafanaApisContextProvider
|
||||
apis={apis}
|
||||
pluginId={'grafana-assistant-app'}
|
||||
apiAllowList={{ addPanel: ['grafana-test-app', 'grafana-assistant-app'] }}
|
||||
>
|
||||
{children}
|
||||
</RestrictedGrafanaApisContextProvider>
|
||||
),
|
||||
});
|
||||
// @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 }) => (
|
||||
<RestrictedGrafanaApisContextProvider
|
||||
apis={apis}
|
||||
pluginId={'grafana-restricted-app'}
|
||||
apiAllowList={{ addPanel: ['grafana-authorised-app'] }}
|
||||
>
|
||||
{children}
|
||||
</RestrictedGrafanaApisContextProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// @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<RestrictedGrafanaApisContextType, unknown>;
|
||||
|
||||
result = renderHook(() => useRestrictedGrafanaApis(), {
|
||||
wrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<RestrictedGrafanaApisContextProvider apis={apis} pluginId={'grafana-test-app'} apiAllowList={{ addPanel: [] }}>
|
||||
{children}
|
||||
</RestrictedGrafanaApisContextProvider>
|
||||
),
|
||||
});
|
||||
// @ts-expect-error No APIs are defined yet
|
||||
expect(result.result.current.addPanel).not.toBeDefined();
|
||||
|
||||
result = renderHook(() => useRestrictedGrafanaApis(), {
|
||||
wrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<RestrictedGrafanaApisContextProvider apis={apis} pluginId={'grafana-test-app'}>
|
||||
{children}
|
||||
</RestrictedGrafanaApisContextProvider>
|
||||
),
|
||||
});
|
||||
// @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 }) => (
|
||||
<RestrictedGrafanaApisContextProvider
|
||||
apis={apis}
|
||||
pluginId={'grafana-test-app'}
|
||||
apiBlockList={{ addPanel: ['grafana-test-app'] }}
|
||||
>
|
||||
{children}
|
||||
</RestrictedGrafanaApisContextProvider>
|
||||
),
|
||||
});
|
||||
// @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 }) => (
|
||||
<RestrictedGrafanaApisContextProvider
|
||||
apis={apis}
|
||||
pluginId={'myorg-test-app'}
|
||||
apiBlockList={{ addPanel: [/^myorg-/] }}
|
||||
>
|
||||
{children}
|
||||
</RestrictedGrafanaApisContextProvider>
|
||||
),
|
||||
});
|
||||
// @ts-expect-error No APIs are defined yet
|
||||
expect(result.current.addPanel).not.toBeDefined();
|
||||
});
|
||||
});
|
|
@ -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<T> = 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<RestrictedGrafanaApisContextTypeInternal>;
|
||||
|
||||
// A type for allowing / blocking plugins for a given API
|
||||
export type RestrictedGrafanaApisAllowList = Partial<
|
||||
Record<keyof RestrictedGrafanaApisContextType | string, Array<string | RegExp>>
|
||||
>;
|
||||
|
||||
export const RestrictedGrafanaApisContext = createContext<RestrictedGrafanaApisContextType>({});
|
||||
|
||||
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<Props>): ReactElement {
|
||||
const { children, pluginId, apis, apiAllowList, apiBlockList } = props;
|
||||
const allowedApis = useMemo(() => {
|
||||
const allowedApis: RestrictedGrafanaApisContextType = {};
|
||||
|
||||
for (const api of Object.keys(apis) as Array<keyof RestrictedGrafanaApisContextType>) {
|
||||
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 <RestrictedGrafanaApisContext.Provider value={allowedApis}>{children}</RestrictedGrafanaApisContext.Provider>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -311,6 +311,8 @@ export interface GrafanaConfig {
|
|||
exploreDefaultTimeOffset: string;
|
||||
exploreHideLogsDownload: boolean;
|
||||
quickRanges?: TimeOption[];
|
||||
pluginRestrictedAPIsAllowList?: Record<string, string[]>;
|
||||
pluginRestrictedAPIsBlockList?: Record<string, string[]>;
|
||||
|
||||
// The namespace to use for kubernetes apiserver requests
|
||||
namespace: string;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -242,6 +242,8 @@ export class GrafanaBootConfig {
|
|||
exploreDefaultTimeOffset = '1h';
|
||||
exploreHideLogsDownload?: boolean;
|
||||
quickRanges?: TimeOption[];
|
||||
pluginRestrictedAPIsAllowList?: Record<string, string[]>;
|
||||
pluginRestrictedAPIsBlockList?: Record<string, string[]>;
|
||||
|
||||
/**
|
||||
* Language used in Grafana's UI. This is after the user's preference (or deteceted locale) is resolved to one of
|
||||
|
|
|
@ -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"`
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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
|
||||
|
|
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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) {
|
|||
/>
|
||||
)}
|
||||
>
|
||||
<ExtensionRegistriesProvider
|
||||
registries={{
|
||||
addedLinksRegistry: addedLinksRegistry.readOnly(),
|
||||
addedComponentsRegistry: addedComponentsRegistry.readOnly(),
|
||||
exposedComponentsRegistry: exposedComponentsRegistry.readOnly(),
|
||||
addedFunctionsRegistry: addedFunctionsRegistry.readOnly(),
|
||||
}}
|
||||
>
|
||||
<plugin.root
|
||||
meta={plugin.meta}
|
||||
basename={location.pathname}
|
||||
onNavChanged={onNavChanged}
|
||||
query={queryParams}
|
||||
path={location.pathname}
|
||||
/>
|
||||
</ExtensionRegistriesProvider>
|
||||
<RestrictedGrafanaApisProvider pluginId={pluginId}>
|
||||
<ExtensionRegistriesProvider
|
||||
registries={{
|
||||
addedLinksRegistry: addedLinksRegistry.readOnly(),
|
||||
addedComponentsRegistry: addedComponentsRegistry.readOnly(),
|
||||
exposedComponentsRegistry: exposedComponentsRegistry.readOnly(),
|
||||
addedFunctionsRegistry: addedFunctionsRegistry.readOnly(),
|
||||
}}
|
||||
>
|
||||
<plugin.root
|
||||
meta={plugin.meta}
|
||||
basename={location.pathname}
|
||||
onNavChanged={onNavChanged}
|
||||
query={queryParams}
|
||||
path={location.pathname}
|
||||
/>
|
||||
</ExtensionRegistriesProvider>
|
||||
</RestrictedGrafanaApisProvider>
|
||||
</PluginErrorBoundary>
|
||||
</PluginContextProvider>
|
||||
);
|
||||
|
|
|
@ -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({ ... });
|
||||
}
|
||||
```
|
|
@ -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 (
|
||||
<RestrictedGrafanaApisContextProvider
|
||||
pluginId={pluginId}
|
||||
apis={restrictedGrafanaApis}
|
||||
apiAllowList={config.bootData.settings.pluginRestrictedAPIsAllowList}
|
||||
apiBlockList={config.bootData.settings.pluginRestrictedAPIsBlockList}
|
||||
>
|
||||
{children}
|
||||
</RestrictedGrafanaApisContextProvider>
|
||||
);
|
||||
}
|
|
@ -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 = <T,>({
|
|||
return (
|
||||
<PluginContextProvider meta={pluginMeta}>
|
||||
<ExtensionErrorBoundary pluginId={pluginId} extensionTitle={extensionTitle} log={log}>
|
||||
<Component
|
||||
{...writableProxy(props, { log, source: 'extension', pluginId, pluginVersion: pluginMeta.info?.version })}
|
||||
/>
|
||||
<RestrictedGrafanaApisProvider pluginId={pluginId}>
|
||||
<Component
|
||||
{...writableProxy(props, { log, source: 'extension', pluginId, pluginVersion: pluginMeta.info?.version })}
|
||||
/>
|
||||
</RestrictedGrafanaApisProvider>
|
||||
</ExtensionErrorBoundary>
|
||||
</PluginContextProvider>
|
||||
);
|
||||
|
|
Loading…
Reference in New Issue