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.
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]