Authorization bypass / Access control

MEDIUM
grafana/grafana
Commit: 0b3b7669d3ee
Affected: All Grafana versions prior to 12.4.0 (i.e., <=12.3.x).
2026-06-22 13:50 UTC

Description

The commit introduces a centralized helper isItemManagedByRepository and replaces ad-hoc checks (e.g., folder.managedBy, AnnoKeyManagerKind annotations) across provisioning-related UI and API paths with this helper. This rework fixes potential authorization/privacy gaps where resources could be misclassified as repository-managed, leading to incorrect visibility or provisioning permissions. By consolidating the authorization logic for determining whether a resource (folder/dashboard) is managed by the repository, the patch reduces the risk of bypasses in provisioning flows and access control decisions.

Proof of Concept

PoC (prototype for a test environment): Background: Prior to 12.4.0, some code paths used ad-hoc checks such as folder.managedBy or annotations (e.g., AnnoKeyManagerKind) to decide if a resource is provisioned by the repo. If an attacker could manipulate the resource annotations or properties in a way that makes the system recognize it as repo-managed on some paths but not others, provisioning permissions or visibility could be bypassed. Steps (in a isolated test environment): 1) Ensure Grafana instance is version <= 12.3.x with provisioning features enabled. 2) Create or identify a folder that should not be considered repository-managed (no legitimate repo association). 3) Patch the folder to attempt to mark it as repository-managed via annotation (or via a crafted folder object) using the AnnoKeyManagerKind/Repo value: curl -X PATCH https://grafana.example/api/folders/{folderUid} \ -H 'Authorization: Bearer <TOKEN>' \ -H 'Content-Type: application/json' \ -d '{ "title": "TestFolder", "annotations": { "AnnoKeyManagerKind": "Repo" } }' 4) Attempt to perform a provisioning-related action that should be restricted for non-repo folders (e.g., creating a new provisioned sub-folder or moving/deleting a resource within that folder) via the UI or REST API. 5) Observe whether the UI/API now treats the patched folder as repo-managed across multiple code paths. If inconsistent checks exist (some paths using the old ad-hoc logic and others using the new centralized helper), you may see that certain endpoints grant provisioning privileges while others do not, indicating an authorization mismatch before the fix. Notes: - The exact API shape and annotation keys depend on the Grafana deployment and the constants used in code (AnnoKeyManagerKind, ManagerKind.Repo). Use the repository’s actual constants from the source when crafting a real PoC in your environment. - This PoC is intended for a safe test environment to validate that the centralized isItemManagedByRepository-based checks align across UI and API surfaces and that misclassifications no longer slip through pre-fix.

Commit Details

Author: Roberto Jiménez Sánchez

Date: 2026-06-22 10:16 UTC

Message:

Provisioning: Generic managed-resource helpers (#126382) Provisioning: Add generic managed-resource helpers and consolidate callsites Extracts the resource-agnostic managed/provisioned helpers from #125902 into public/app/features/provisioning/utils/managedResource.ts and routes the existing inline annotation and managedBy checks through them. Fixes https://github.com/grafana/git-ui-sync-project/issues/1189 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

Triage Assessment

Vulnerability Type: Authorization bypass / Access control

Confidence: MEDIUM

Reasoning:

The commit refactors provisioning logic to use a generic managed-resource helper (isItemManagedByRepository) for determining whether a folder/dashboard is managed/provisioned by the repository. This changes multiple authorization-related checks (e.g., isProvisionedFolder, isProvisionedRootFolder, UI visibility, and callsites) to rely on a centralized, explicit notion of repository-managed resources instead of ad-hoc annotations. That reduces risk of incorrect permission or visibility decisions and potential bypasses of provisioning restrictions, addressing authorization/privacy gaps in provisioning flows.

Verification Assessment

Vulnerability Type: Authorization bypass / Access control

Confidence: MEDIUM

Affected Versions: All Grafana versions prior to 12.4.0 (i.e., <=12.3.x).

Code Diff

diff --git a/public/app/core/components/NestedFolderPicker/FolderRepo.tsx b/public/app/core/components/NestedFolderPicker/FolderRepo.tsx index dc5e483e89010..73eb6e8190f7a 100644 --- a/public/app/core/components/NestedFolderPicker/FolderRepo.tsx +++ b/public/app/core/components/NestedFolderPicker/FolderRepo.tsx @@ -9,6 +9,7 @@ import { useGetResourceRepositoryView, } from 'app/features/provisioning/hooks/useGetResourceRepositoryView'; import { useIsProvisionedInstance } from 'app/features/provisioning/hooks/useIsProvisionedInstance'; +import { isItemManagedByRepository } from 'app/features/provisioning/utils/managedResource'; import { type DashboardViewItem } from 'app/features/search/types'; import { type FolderDTO } from 'app/types/folders'; @@ -58,7 +59,7 @@ function getCanSkipEarly(folder: FolderDTO | DashboardViewItem | undefined): boo if (hasParent) { return true; } - const isNotManaged = folder.managedBy !== ManagerKind.Repo; + const isNotManaged = !isItemManagedByRepository(folder); if (isNotManaged) { return true; } diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx index 39aac4e864ce0..8fcb225ee897c 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx @@ -14,12 +14,12 @@ import { useNavModel } from 'app/features/browse-dashboards/hooks/useNavModel'; import { useDispatch } from 'app/types/store'; import { FolderRepo } from '../../core/components/NestedFolderPicker/FolderRepo'; -import { ManagerKind } from '../apiserver/types'; import { TemplateDashboardModal } from '../dashboard/dashgrid/DashboardLibrary/TemplateDashboardModal'; import { ProvisionedFolderPreviewBanner } from '../provisioning/components/Folders/ProvisionedFolderPreviewBanner'; import { RenameProvisionedFolderForm } from '../provisioning/components/Folders/RenameProvisionedFolderForm'; import { OrphanedResourceBanner } from '../provisioning/components/Shared/OrphanedResourceBanner'; import { RepoViewStatus, useGetResourceRepositoryView } from '../provisioning/hooks/useGetResourceRepositoryView'; +import { isItemManagedByRepository } from '../provisioning/utils/managedResource'; import { useSearchStateManager } from '../search/state/SearchStateManager'; import { getSearchPlaceholder } from '../search/tempI18nPhrases'; @@ -110,7 +110,7 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string const folder = folderDTO ?? rootFolderDTO; const { canEditFolders, canDeleteFolders, canDeleteDashboards, canEditDashboards } = getFolderPermissions(folder); - const isProvisionedFolder = folder?.managedBy === ManagerKind.Repo; + const isProvisionedFolder = isItemManagedByRepository(folder); const isRepoRootFolder = isProvisionedFolder && folderUID === repository?.name; const [showRenameDrawer, setShowRenameDrawer] = useState(false); const showEditTitle = canEditFolders && !!folderUID; diff --git a/public/app/features/browse-dashboards/api/isProvisioned.ts b/public/app/features/browse-dashboards/api/isProvisioned.ts index 327318e51c66f..1bc1abe615030 100644 --- a/public/app/features/browse-dashboards/api/isProvisioned.ts +++ b/public/app/features/browse-dashboards/api/isProvisioned.ts @@ -1,14 +1,14 @@ import { type Folder } from 'app/api/clients/folder/v1beta1'; -import { AnnoKeyManagerKind, ManagerKind } from 'app/features/apiserver/types'; +import { isManagedByRepository } from 'app/features/provisioning/utils/managedResource'; import { type DashboardDTO } from 'app/types/dashboard'; import { type DashboardWithAccessInfo } from '../../dashboard/api/types'; export function isProvisionedDashboard(dashboard: DashboardDTO | DashboardWithAccessInfo<unknown>) { const annotations = 'meta' in dashboard ? dashboard.meta.k8s?.annotations : dashboard.metadata.annotations; - return annotations?.[AnnoKeyManagerKind] === ManagerKind.Repo; + return isManagedByRepository({ metadata: { annotations } }); } export function isProvisionedFolder(folder: Folder) { - return folder.metadata.annotations?.[AnnoKeyManagerKind] === ManagerKind.Repo; + return isManagedByRepository(folder); } diff --git a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx index 3bebfc45d002b..aa7306ca17e5b 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx @@ -4,10 +4,10 @@ import { Trans, t } from '@grafana/i18n'; import { config, reportInteraction } from '@grafana/runtime'; import { Button, Drawer, Stack, Text } from '@grafana/ui'; import { appEvents } from 'app/core/app_events'; -import { ManagerKind } from 'app/features/apiserver/types'; import { BulkDeleteProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkDeleteProvisionedResource'; import { BulkMoveProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkMoveProvisionedResource'; import { useSelectionProvisioningStatus } from 'app/features/provisioning/hooks/useSelectionProvisioningStatus'; +import { isItemManagedByRepository } from 'app/features/provisioning/utils/managedResource'; import { useSearchStateManager } from 'app/features/search/state/SearchStateManager'; import { ShowModalReactEvent } from 'app/types/events'; import { type FolderDTO } from 'app/types/folders'; @@ -45,7 +45,7 @@ export function BrowseActions({ folderDTO }: Props) { const { hasProvisioned, hasNonProvisioned } = useSelectionProvisioningStatus( selectedItems, - folderDTO?.managedBy === ManagerKind.Repo + isItemManagedByRepository(folderDTO) ); const isSearching = stateManager.hasSearchFilters(); diff --git a/public/app/features/browse-dashboards/components/CheckboxCell.tsx b/public/app/features/browse-dashboards/components/CheckboxCell.tsx index 393cb1184a16e..0b4f12337eaf9 100644 --- a/public/app/features/browse-dashboards/components/CheckboxCell.tsx +++ b/public/app/features/browse-dashboards/components/CheckboxCell.tsx @@ -4,9 +4,9 @@ import { type GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { t } from '@grafana/i18n'; import { Checkbox, Tooltip, useStyles2 } from '@grafana/ui'; -import { ManagerKind } from 'app/features/apiserver/types'; import { useIsProvisionedInstance } from 'app/features/provisioning/hooks/useIsProvisionedInstance'; import { useSelectionRepoValidation } from 'app/features/provisioning/hooks/useSelectionRepoValidation'; +import { isItemManagedByRepository } from 'app/features/provisioning/utils/managedResource'; import { getReadOnlyTooltipText } from 'app/features/provisioning/utils/tooltip'; import { useSelector } from 'app/types/store'; @@ -46,7 +46,7 @@ export default function CheckboxCell({ } // Disable the checkbox for the root provisioned folder (if the entire instance is not provisioned) - if (!isProvisionedInstance && item.managedBy === ManagerKind.Repo && !item.parentUID) { + if (!isProvisionedInstance && isItemManagedByRepository(item) && !item.parentUID) { return <CheckboxSpacer />; } diff --git a/public/app/features/browse-dashboards/components/CreateNewButton.tsx b/public/app/features/browse-dashboards/components/CreateNewButton.tsx index 3c16c3129a803..5dc5087a56c78 100644 --- a/public/app/features/browse-dashboards/components/CreateNewButton.tsx +++ b/public/app/features/browse-dashboards/components/CreateNewButton.tsx @@ -16,6 +16,7 @@ import { DashboardLibraryInteractions } from 'app/features/dashboard/dashgrid/Da import { type RepoType } from 'app/features/provisioning/Wizard/types'; import { NewProvisionedFolderForm } from 'app/features/provisioning/components/Folders/NewProvisionedFolderForm'; import { useIsProvisionedInstance } from 'app/features/provisioning/hooks/useIsProvisionedInstance'; +import { isItemManagedByRepository } from 'app/features/provisioning/utils/managedResource'; import { getReadOnlyTooltipText } from 'app/features/provisioning/utils/tooltip'; import { getImportPhrase, @@ -26,8 +27,6 @@ import { } from 'app/features/search/tempI18nPhrases'; import { type FolderDTO } from 'app/types/folders'; -import { ManagerKind } from '../../apiserver/types'; - import { NewFolderForm } from './NewFolderForm'; interface Props { @@ -181,7 +180,7 @@ export default function CreateNewButton({ onClose={() => setShowNewFolderDrawer(false)} size="sm" > - {parentFolder?.managedBy === ManagerKind.Repo || isProvisionedInstance ? ( + {isItemManagedByRepository(parentFolder) || isProvisionedInstance ? ( <NewProvisionedFolderForm onDismiss={() => setShowNewFolderDrawer(false)} parentFolder={parentFolder} /> ) : ( <NewFolderForm diff --git a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx index b1fa30137008d..76ce9d37a358c 100644 --- a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx +++ b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx @@ -13,13 +13,13 @@ import { BulkMoveProvisionedResource } from 'app/features/provisioning/component import { DeleteProvisionedFolderForm } from 'app/features/provisioning/components/Folders/DeleteProvisionedFolderForm'; import { FolderPermissions } from 'app/features/provisioning/components/Folders/MissingFolderMetadataBanner'; import { useIsProvisionedInstance } from 'app/features/provisioning/hooks/useIsProvisionedInstance'; +import { isItemManagedByRepository } from 'app/features/provisioning/utils/managedResource'; import { AccessControlAction } from 'app/types/accessControl'; import { ShowModalReactEvent } from 'app/types/events'; import { type FolderDTO } from 'app/types/folders'; import { useDeleteFolderMutationFacade, useMoveFolderMutationFacade } from '../../../api/clients/folder/v1beta1/hooks'; import { extractErrorMessage } from '../../../api/utils'; -import { ManagerKind } from '../../apiserver/types'; import { getFolderPermissions } from '../permissions'; import { DeleteModal } from './BrowseActions/DeleteModal'; @@ -50,7 +50,7 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props) canSetPermissions, } = getFolderPermissions(folder); - const isProvisionedFolder = folder.managedBy === ManagerKind.Repo; + const isProvisionedFolder = isItemManagedByRepository(folder); const isProvisionedRootFolder = isProvisionedFolder && !isProvisionedInstance && folder.parentUid === undefined; // Can only move folders when the folder is not provisioned const canMoveFolder = canEditFolders && !isProvisionedRootFolder && !isReadOnlyRepo; diff --git a/public/app/features/folders/state/navModel.ts b/public/app/features/folders/state/navModel.ts index 77d219a55c535..c8311b6b38e56 100644 --- a/public/app/features/folders/state/navModel.ts +++ b/public/app/features/folders/state/navModel.ts @@ -3,7 +3,7 @@ import { t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; import { getNavSubTitle } from 'app/core/utils/navBarItem-translations'; -import { ManagerKind } from 'app/features/apiserver/types'; +import { isItemManagedByRepository } from 'app/features/provisioning/utils/managedResource'; import { AccessControlAction } from 'app/types/accessControl'; import { type FolderDTO, type FolderParent } from 'app/types/folders'; @@ -19,7 +19,7 @@ export function buildNavModel( counts?: { panels: number; rules: number } ): NavModelItem { const parents = parentsArg ?? ('parents' in folder ? folder.parents : undefined); - const isProvisioned = 'managedBy' in folder ? folder.managedBy === ManagerKind.Repo : false; + const isProvisioned = 'managedBy' in folder && isItemManagedByRepository(folder); const model: NavModelItem = { icon: 'folder', diff --git a/public/app/features/provisioning/hooks/useGetResourceRepositoryView.ts b/public/app/features/provisioning/hooks/useGetResourceRepositoryView.ts index a26dac86865a1..20347f4f6ddde 100644 --- a/public/app/features/provisioning/hooks/useGetResourceRepositoryView.ts +++ b/public/app/features/provisioning/hooks/useGetResourceRepositoryView.ts @@ -3,9 +3,9 @@ import { skipToken } from '@reduxjs/toolkit/query/react'; import { config, isFetchError } from '@grafana/runtime'; import { type Folder, useGetFolderQuery } from 'app/api/clients/folder/v1beta1'; import { type RepositoryView, useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1'; -import { AnnoKeyManagerIdentity, AnnoKeyManagerKind, ManagerKind } from 'app/features/apiserver/types'; import { type RepoType } from '../Wizard/types'; +import { getManagerIdentity, isManagedByRepository } from '../utils/managedResource'; import { getIsReadOnlyRepo } from '../utils/repository'; interface GetResourceRepositoryArgs { @@ -101,13 +101,12 @@ const useResourceRepositoryViewData = ({ // failing closed and blocking unrelated flows like dashboard import. // Repo-annotated folders and name-based lookups stay fail-closed: git-sync flows // cannot proceed without the settings data anyway. - const annotatedManagerKind = folder?.metadata?.annotations?.[AnnoKeyManagerKind]; if ( isFetchError(settingsError) && settingsError.status === 403 && !name && !folderError && - annotatedManagerKind !== ManagerKind.Repo + !(folder && isManagedByRepository(folder)) ) { return { folder, isInstanceManaged: false, isReadOnlyRepo: false, status: RepoViewStatus.Ready }; } @@ -170,9 +169,8 @@ const useResourceRepositoryViewData = ({ // For nested folders we need to see what the folder thinks. // Only treat as repo-managed if the manager kind is explicitly 'repo' — // folders managed by plugins, terraform, kubectl, etc. should not be matched against provisioning repos. - const annotatedManagerKind = folder?.metadata?.annotations?.[AnnoKeyManagerKind]; - const annotatedFolderName = folder?.metadata?.annotations?.[AnnoKeyManagerIdentity]; - if (annotatedFolderName && annotatedManagerKind === ManagerKind.Repo) { + const annotatedFolderName = folder ? getManagerIdentity(folder) : undefined; + if (annotatedFolderName && folder && isManagedByRepository(folder)) { repository = items.find((repo) => repo.name === annotatedFolderName); if (repository) { return { diff --git a/public/app/features/provisioning/hooks/useSelectionProvisioningStatus.ts b/public/app/features/provisioning/hooks/useSelectionPr ... [truncated]
← Back to Alerts View on GitHub →