Authorization Bypass / Privilege Escalation (RBAC UI gating)
Description
The commit introduces RBAC-based gating for the Dashboard Templates UI. It adds DashboardTemplatesRead/Write to AccessControlAction, introduces canReadDashboardTemplates/canManageDashboardTemplates helpers, and hides custom template UI elements (including the custom templates tab, the save-as-template action, and related modal mounts) unless the user has the appropriate permissions. The changes ensure the UI does not present actions that would be rejected by the backend authorizer, replacing previous behavior where certain template actions could be exposed in the UI without confirming the user has the necessary backend permissions. This addresses potential authorization bypass/privilege escalation vectors via the UI by aligning frontend visibility with API permissions. The changes touch multiple UI paths (QuickAdd, NewActionsButton, DashboardScenePage) and include test updates to reflect the gating logic.
Proof of Concept
Proof-of-Concept (Playwright-based) to demonstrate UI-level authorization gating prior to this fix:
Prerequisites:
- Grafana instance with RBAC (Enterprise) enabled.
- Two test users configured:
1) user-without-read: dashboards:create = true, dashboardtemplates:read = false
2) user-with-read: dashboards:create = true, dashboardtemplates:read = true
- Access to a dashboard for quick add/template interaction.
PoC steps (reproduce the vulnerability before the fix):
1) Log in as user-without-read and navigate to a dashboard listing or create-new-dashboard flow (e.g., http(s)://<grafana>/dashboard/new).
2) Open the Quick Add menu (look for a button labeled "New" and activate it).
3) Observe whether a menu option labeled "Use template" is visible. If the option appears, the user is being offered a template action despite lacking dashboardtemplates:read permission, indicating an authorization bypass risk.
4) (Optional) Attempt to click "Use template" and observe the API call and response. If the backend rejects due to missing read permission, this still demonstrates an insecure UI exposure (the UI hints at an action that may rely on API checks).
5) Repeat steps 1–4 as user-with-read to confirm the action is conditionally visible only when read permission is granted.
Sample Playwright script (Node.js):
const { chromium } = require('playwright');
async function testVisibility(baseUrl, username, password) {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(`${baseUrl}/login`);
await page.fill('input[name="username"]', username);
await page.fill('input[name="password"]', password);
await page.click('button[type="submit"]');
// Navigate to new dashboard screen
await page.goto(`${baseUrl}/dashboard/new`);
// Open Quick Add / New
await page.click('button:has-text("New")');
// Check for the presence of the template action
const useTemplate = await page.$('role=menuitem[name="Use template"]');
if (useTemplate) {
const text = await useTemplate.textContent();
console.log(`VULNERABLE: Use template is visible without dashboardtemplates:read. Text: ${text}`);
} else {
console.log('OK: Use template is not visible without dashboardtemplates:read');
}
await browser.close();
}
// Example usage (provide real URLs and credentials in your env)
// testVisibility('https://grafana.example.com', 'user-without-read', 'password');
// testVisibility('https://grafana.example.com', 'user-with-read', 'password');
This PoC will log whether the "Use template" option is visible in the UI for a user lacking the dashboardtemplates:read permission. If visible for user-without-read, it demonstrates the vulnerability path (UI would offer an action the backend may reject or require additional permissions). After the fix in this commit, the option should not be rendered for users without the read permission, reducing the risk of UI-driven authorization bypass.
Commit Details
Author: Juan Cabanas
Date: 2026-07-02 21:22 UTC
Message:
Dashboard Template: Add dropdown menu with Delete option + manage UI based on permissions (#127586)
* delete button added in dashboard card with dropdown menu
* improvements
* overlay fix
* test xi
* missing slug in test
* types improved
* editor permissions to render UI elements
* DashboardTemplates: Gate custom templates UI on RBAC read/write permissions
Replace the interim org-role (isEditor) gating with the dashboardtemplates:read /
dashboardtemplates:write RBAC actions:
- Add DashboardTemplatesRead/Write to AccessControlAction.
- Add canReadDashboardTemplates/canManageDashboardTemplates helpers.
- Hide the custom templates tab (and entry points) without read permission via
useTemplateDashboardsAvailability; gate the save-as-template item and the
DashboardScenePage modal mount on the same permission.
Mirrors the backend authorizer so the UI never offers actions the API rejects.
* test fixed
Triage Assessment
Vulnerability Type: Authorization bypass / Privilege escalation
Confidence: HIGH
Reasoning:
The commit adds RBAC-based gating for Dashboard Templates UI (read/write permissions) and aligns UI behavior with backend authorizers to prevent presenting actions the API would reject. This reduces the risk of UI-based authorization bypass and ensures users cannot trigger template actions they lack permissions for.
Verification Assessment
Vulnerability Type: Authorization Bypass / Privilege Escalation (RBAC UI gating)
Confidence: HIGH
Affected Versions: <= 12.4.0
Code Diff
diff --git a/public/app/core/components/AppChrome/QuickAdd/QuickAdd.test.tsx b/public/app/core/components/AppChrome/QuickAdd/QuickAdd.test.tsx
index b88b716fae3f5..1f7f355e4ebc0 100644
--- a/public/app/core/components/AppChrome/QuickAdd/QuickAdd.test.tsx
+++ b/public/app/core/components/AppChrome/QuickAdd/QuickAdd.test.tsx
@@ -7,11 +7,13 @@ import { type DataSourceInstanceListItem, type NavModelItem } from '@grafana/dat
import { config, reportInteraction } from '@grafana/runtime';
import { useDataSourceInstanceList } from '@grafana/runtime/unstable';
import { setTestFlags } from '@grafana/test-utils/unstable';
+import { contextSrv } from 'app/core/services/context_srv';
import { NewDashboardLibraryInteractions } from 'app/features/dashboard/dashgrid/DashboardLibrary/analytics/main';
import { CONTENT_KINDS, SOURCE_ENTRY_POINTS } from 'app/features/dashboard/dashgrid/DashboardLibrary/constants';
import { getDashboardTemplatesTab } from 'app/features/dashboard/dashgrid/DashboardLibrary/enterprise-components/DashboardTemplatesTabExtension';
import { DashboardLibraryInteractions } from 'app/features/dashboard/dashgrid/DashboardLibrary/interactions';
import { configureStore } from 'app/store/configureStore';
+import { AccessControlAction } from 'app/types/accessControl';
import { QuickAdd } from './QuickAdd';
@@ -176,12 +178,22 @@ describe('QuickAdd', () => {
});
describe('Use template button', () => {
+ let originalPermissions: typeof contextSrv.user.permissions;
+
beforeEach(() => {
config.featureToggles.dashboardTemplates = true;
// Reset to defaults: a test datasource is available, custom templates are off.
mockUseDataSourceInstanceList.mockReturnValue({ isLoading: false, items: [defaultTestDataSource] });
mockGetDashboardTemplatesTab.mockReturnValue(null);
setTestFlags({ 'grafana.customDashboardTemplates': false });
+ // Custom templates require dashboardtemplates:read; grant it by default (grafana-provisioned
+ // templates don't depend on it), and revoke it in the read-gating case.
+ originalPermissions = contextSrv.user.permissions;
+ contextSrv.user.permissions = { [AccessControlAction.DashboardTemplatesRead]: true };
+ });
+
+ afterEach(() => {
+ contextSrv.user.permissions = originalPermissions;
});
it('shows a `Use template` button when the feature flag is enabled and a test data source exists', async () => {
@@ -224,6 +236,18 @@ describe('QuickAdd', () => {
expect(screen.getByRole('menuitem', { name: 'Use template' })).toBeInTheDocument();
});
+ it('does not show a `Use template` button for custom-only templates without dashboardtemplates:read', async () => {
+ config.featureToggles.dashboardTemplates = false;
+ mockUseDataSourceInstanceList.mockReturnValue({ isLoading: false, items: [] });
+ mockGetDashboardTemplatesTab.mockReturnValue(() => null);
+ setTestFlags({ 'grafana.customDashboardTemplates': true });
+ contextSrv.user.permissions = {};
+
+ setup();
+ await userEvent.click(screen.getByRole('button', { name: 'New' }));
+ expect(screen.queryByRole('menuitem', { name: 'Use template' })).not.toBeInTheDocument();
+ });
+
it('redirects the user to the dashboard from template page when the button is clicked', async () => {
setup();
diff --git a/public/app/features/browse-dashboards/components/CreateNewButton.test.tsx b/public/app/features/browse-dashboards/components/CreateNewButton.test.tsx
index 6d05935bda1f2..dd29c4daf8ae9 100644
--- a/public/app/features/browse-dashboards/components/CreateNewButton.test.tsx
+++ b/public/app/features/browse-dashboards/components/CreateNewButton.test.tsx
@@ -5,9 +5,11 @@ import { type DataSourceInstanceListItem } from '@grafana/data';
import { config } from '@grafana/runtime';
import { useDataSourceInstanceList } from '@grafana/runtime/unstable';
import { setTestFlags } from '@grafana/test-utils/unstable';
+import { contextSrv } from 'app/core/services/context_srv';
import { ManagerKind } from 'app/features/apiserver/types';
import { getDashboardTemplatesTab } from 'app/features/dashboard/dashgrid/DashboardLibrary/enterprise-components/DashboardTemplatesTabExtension';
import { useIsProvisionedInstance } from 'app/features/provisioning/hooks/useIsProvisionedInstance';
+import { AccessControlAction } from 'app/types/accessControl';
import { type FolderDTO } from 'app/types/folders';
import { mockFolderDTO } from '../fixtures/folder.fixture';
@@ -158,12 +160,22 @@ describe('NewActionsButton', () => {
});
describe('Dashboard from template button', () => {
+ let originalPermissions: typeof contextSrv.user.permissions;
+
beforeEach(() => {
config.featureToggles.dashboardTemplates = true;
// Reset to defaults: a test datasource is available, custom templates are off.
mockUseDataSourceInstanceList.mockReturnValue({ isLoading: false, items: [defaultTestDataSource] });
mockGetDashboardTemplatesTab.mockReturnValue(null);
setTestFlags({ 'grafana.customDashboardTemplates': false });
+ // Custom templates require dashboardtemplates:read; grant it by default (grafana-provisioned
+ // templates don't depend on it).
+ originalPermissions = contextSrv.user.permissions;
+ contextSrv.user.permissions = { [AccessControlAction.DashboardTemplatesRead]: true };
+ });
+
+ afterEach(() => {
+ contextSrv.user.permissions = originalPermissions;
});
it('should show a `Use template` button when the feature flag is enabled', async () => {
diff --git a/public/app/features/commandPalette/actions/staticActions.test.tsx b/public/app/features/commandPalette/actions/staticActions.test.tsx
index e1c45608bb027..67dc23da88cbc 100644
--- a/public/app/features/commandPalette/actions/staticActions.test.tsx
+++ b/public/app/features/commandPalette/actions/staticActions.test.tsx
@@ -5,8 +5,10 @@ import { type DataSourceInstanceListItem } from '@grafana/data';
import { config } from '@grafana/runtime';
import { useDataSourceInstanceList } from '@grafana/runtime/unstable';
import { setTestFlags } from '@grafana/test-utils/unstable';
+import { contextSrv } from 'app/core/services/context_srv';
import { getDashboardTemplatesTab } from 'app/features/dashboard/dashgrid/DashboardLibrary/enterprise-components/DashboardTemplatesTabExtension';
import { configureStore } from 'app/store/configureStore';
+import { type UserPermission, AccessControlAction } from 'app/types/accessControl';
import { type CommandPaletteAction } from '../types';
@@ -45,12 +47,21 @@ const hasTemplateAction = (actions: CommandPaletteAction[]) =>
actions.some((action) => action.id === 'browse-template-dashboard');
describe('useStaticActions - dashboard from template action', () => {
+ let originalPermissions: UserPermission | undefined;
+
beforeEach(() => {
config.featureToggles.dashboardTemplates = true;
// Reset to defaults: a test datasource is available, custom templates are off.
mockUseDataSourceInstanceList.mockReturnValue({ isLoading: false, items: [defaultTestDataSource] });
mockGetDashboardTemplatesTab.mockReturnValue(null);
setTestFlags({ 'grafana.customDashboardTemplates': false });
+ // The entry point requires dashboard-create permission, mirroring QuickAdd.
+ originalPermissions = contextSrv.user.permissions;
+ contextSrv.user.permissions = { [AccessControlAction.DashboardsCreate]: true };
+ });
+
+ afterEach(() => {
+ contextSrv.user.permissions = originalPermissions;
});
it('includes the action when the Grafana templates feature toggle is enabled', () => {
@@ -58,6 +69,12 @@ describe('useStaticActions - dashboard from template action', () => {
expect(hasTemplateAction(result.current)).toBe(true);
});
+ it('does not include the action when the user lacks dashboards:create permission', () => {
+ contextSrv.user.permissions = {};
+ const { result } = renderStaticActions();
+ expect(hasTemplateAction(result.current)).toBe(false);
+ });
+
it('does not include the action when neither templates feature is enabled', () => {
config.featureToggles.dashboardTemplates = false;
mockUseDataSourceInstanceList.mockReturnValue({ isLoading: false, items: [] });
@@ -70,8 +87,25 @@ describe('useStaticActions - dashboard from template action', () => {
mockUseDataSourceInstanceList.mockReturnValue({ isLoading: false, items: [] });
mockGetDashboardTemplatesTab.mockReturnValue(() => null);
setTestFlags({ 'grafana.customDashboardTemplates': true });
+ // Custom templates require the read permission in addition to dashboard-create.
+ contextSrv.user.permissions = {
+ [AccessControlAction.DashboardsCreate]: true,
+ [AccessControlAction.DashboardTemplatesRead]: true,
+ };
const { result } = renderStaticActions();
expect(hasTemplateAction(result.current)).toBe(true);
});
+
+ it('does not include the action for custom-only templates without dashboardtemplates:read', () => {
+ config.featureToggles.dashboardTemplates = false;
+ mockUseDataSourceInstanceList.mockReturnValue({ isLoading: false, items: [] });
+ mockGetDashboardTemplatesTab.mockReturnValue(() => null);
+ setTestFlags({ 'grafana.customDashboardTemplates': true });
+ // Has dashboard-create but not the templates read permission.
+ contextSrv.user.permissions = { [AccessControlAction.DashboardsCreate]: true };
+
+ const { result } = renderStaticActions();
+ expect(hasTemplateAction(result.current)).toBe(false);
+ });
});
diff --git a/public/app/features/commandPalette/actions/staticActions.ts b/public/app/features/commandPalette/actions/staticActions.ts
index 3fcf3bb729745..595fa32bad426 100644
--- a/public/app/features/commandPalette/actions/staticActions.ts
+++ b/public/app/features/commandPalette/actions/staticActions.ts
@@ -10,12 +10,14 @@ import {
shouldRenderInviteUserButton,
performInviteUserClick,
} from 'app/core/components/AppChrome/TopBar/InviteUserButtonUtils';
+import { contextSrv } from 'app/core/services/context_srv';
import { changeTheme } from 'app/core/services/theme';
import { currentMockApiState, toggleMockApiAndReload, togglePseudoLocale } from 'app/dev-utils';
import { NewDashboardLibraryInteractions } from 'app/features/dashboard/dashgrid/DashboardLibrary/analytics/main';
import { CONTENT_KINDS, SOURCE_ENTRY_POINTS } from 'app/features/dashboard/dashgrid/DashboardLibrary/constants';
import { useTemplateDashboardsAvailability } from 'app/features/dashboard/dashgrid/DashboardLibrary/hooks/useTemplateDashboardsAvailability';
import { DashboardLibraryInteractions } from 'app/features/dashboard/dashgrid/DashboardLibrary/interactions';
+import { AccessControlAction } from 'app/types/accessControl';
import { useSelector } from 'app/types/store';
import { type CommandPaletteAction } from '../types';
@@ -163,7 +165,9 @@ export function useStaticActions(): CommandPaletteAction[] {
return useMemo(() => {
let navBarActions = navTreeToActions(navBarTree);
- if (isTemplateDashboardsAvailable) {
+ const canCreateDashboard = contextSrv.hasPermission(AccessControlAction.DashboardsCreate);
+
+ if (isTemplateDashboardsAvailable && canCreateDashboard) {
const navBarActionsWithoutActions = navBarActions.filter((action) => action.priority !== ACTIONS_PRIORITY);
const navBarActionsWithActions = navBarActions.filter((action) => action.priority === ACTIONS_PRIORITY);
diff --git a/public/app/features/dashboard-scene/analytics/dashboard-templates/types.ts b/public/app/features/dashboard-scene/analytics/dashboard-templates/types.ts
index 3a995b84d98f6..995d411396f03 100644
--- a/public/app/features/dashboard-scene/analytics/dashboard-templates/types.ts
+++ b/public/app/features/dashboard-scene/analytics/dashboard-templates/types.ts
@@ -1,4 +1,5 @@
import { type EventProperty } from '@grafana/runtime/unstable';
+import { type EventLocation } from 'app/features/dashboard/dashgrid/DashboardLibrary/constants';
export interface SaveAsOpenedProperties extends EventProperty {
/** UID of the source dashboard the user is saving as a template. Empty for new dashboards. */
@@ -49,6 +50,8 @@ export interface UpdatedMetadataProperties extends EventProperty {
export interface DeleteCompletedProperties extends EventProperty {
/** UID of the template resource that was deleted. */
templateUid: string;
+ /** The UI location where the template was deleted. */
+ eventLocation: EventLocation;
}
export interface LoadedProperties extends EventProperty {
diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx
index 528d8517453a6..ac56faba99887 100644
--- a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx
+++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx
@@ -4,7 +4,6 @@ import { usePrevious } from 'react-use';
import { PageLayoutType } from '@grafana/data';
import { locationService } from '@grafana/runtime';
-import { useFlagGrafanaCustomDashboardTemplates } from '@grafana/runtime/internal';
import { UrlSyncContextProvider } from '@grafana/scenes';
import { Box } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
@@ -20,7 +19,7 @@ import {
type DashboardPageRouteSearchParams,
} from 'app/features/dashboard/containers/types';
import { TemplateDashboardModal } from 'app/features/dashboard/dashgrid/DashboardLibrary/TemplateDashboardModal';
-import { getDashboardTemplatesTab } from 'app/features/dashboard/dashgrid/DashboardLibrary/enterprise-components/DashboardTemplatesTabExtension';
+import { useTemplateDashboardsAvailability } from 'app/features/dashboard/dashgrid/DashboardLibrary/hooks/useTemplateDashboardsAvailability';
import { getDashboardSceneProfiler } from 'app/features/dashboard/services/DashboardProfiler';
import { DashboardPreviewBanner } from 'app/features/provisioning/components/Dashboards/DashboardPreviewBanner';
import { OrphanedDashboardBanner } from 'app/features/provisioning/components/Dashboards/OrphanedDashboardBanner';
@@ -44,8 +43,9 @@ export interface Props
export function DashboardScenePage({ route, queryParams, location }: Props) {
const params = useParams();
const { type, slug, uid } = params;
- const isCustomDashboardTemplatesEnabled =
- useFlagGrafanaCustomDashboardTemplates() && getDashboardTemplatesTab() !== null;
+ // Custom templates also require the dashboardtemplates:read RBAC permission (the API denies
+ // listing without it), matching useTemplateDashboardsAvailability's showCustomTemplates.
+ const { showCustomTemplates } = useTemplateDashboardsAvailability();
// Used by /dashboard/provisioning/:slug/preview/* to load dashboards based on their file path in a remote repository
// Also used
... [truncated]