FolderRepo: Prevent no basic role user getting infinite api calls on provisioning settings endpoint (#110486)

This commit is contained in:
Yunwen Zheng
2025-09-03 10:17:36 -04:00
committed by GitHub
parent 163ff0c9f1
commit 3a3ea45111
5 changed files with 134 additions and 13 deletions
@@ -0,0 +1,91 @@
import { render, screen, cleanup } from '@testing-library/react';
import { RepositoryView } from 'app/api/clients/provisioning/v0alpha1';
import { ManagerKind } from 'app/features/apiserver/types';
import { DashboardViewItem } from 'app/features/search/types';
import { FolderRepo } from './FolderRepo';
jest.mock('@grafana/runtime', () => ({
config: { featureToggles: { provisioning: true } },
}));
const mockUseGetFrontendSettingsQuery = jest.fn();
jest.mock('app/api/clients/provisioning/v0alpha1', () => ({
useGetFrontendSettingsQuery: () => mockUseGetFrontendSettingsQuery(),
}));
const mockUseGetResourceRepositoryView = jest.fn();
jest.mock('app/features/provisioning/hooks/useGetResourceRepositoryView', () => ({
useGetResourceRepositoryView: () => mockUseGetResourceRepositoryView(),
}));
function mockSettings(items: Array<Partial<RepositoryView>>) {
mockUseGetFrontendSettingsQuery.mockReturnValue({ data: { items } });
}
function mockRepoView({ isReadOnlyRepo = false, repoType = 'github' }) {
mockUseGetResourceRepositoryView.mockReturnValue({ isReadOnlyRepo, repoType });
}
const MOCK_FOLDER: DashboardViewItem = {
uid: 'A',
managedBy: ManagerKind.Repo,
parentUID: undefined,
kind: 'folder',
title: 'test',
};
function setup({
folder = undefined,
repoViewMock = {},
settingsMock = [],
}: {
folder?: DashboardViewItem;
repoViewMock?: { isReadOnlyRepo?: boolean; repoType?: string };
settingsMock?: Array<Partial<RepositoryView>>;
}) {
mockSettings(settingsMock);
mockRepoView(repoViewMock);
return {
...render(<FolderRepo folder={folder} />),
};
}
describe('FolderRepo', () => {
afterEach(() => {
cleanup();
jest.clearAllMocks();
});
it('returns null when folder is undefined', () => {
setup({ folder: undefined });
expect(screen.queryByText('Read only')).not.toBeInTheDocument();
expect(screen.queryByTitle('Provisioned')).not.toBeInTheDocument();
});
it('returns null when folder has parentUID', () => {
setup({ folder: { ...MOCK_FOLDER, parentUID: 'repo-123' } });
expect(screen.queryByText('Read only')).not.toBeInTheDocument();
expect(screen.queryByTitle('Provisioned')).not.toBeInTheDocument();
});
it('returns null when folder is not managed', () => {
setup({ folder: { ...MOCK_FOLDER, managedBy: undefined } });
expect(screen.queryByText('Read only')).not.toBeInTheDocument();
expect(screen.queryByTitle('Provisioned')).not.toBeInTheDocument();
});
it('returns null when whole instance is provisioned', () => {
setup({ folder: MOCK_FOLDER, settingsMock: [{ target: 'instance' }] });
expect(screen.queryByText('Read only')).not.toBeInTheDocument();
expect(screen.queryByTitle('Provisioned')).not.toBeInTheDocument();
});
it('renders Read only badge when repo is read-only (empty workflows)', () => {
setup({ folder: MOCK_FOLDER, repoViewMock: { isReadOnlyRepo: true, repoType: 'github' } });
expect(screen.getByText('Read only')).toBeInTheDocument();
expect(screen.queryByTitle('Provisioned')).toBeInTheDocument();
});
});
@@ -1,13 +1,14 @@
import { t } from '@grafana/i18n';
import { Badge, Stack } from '@grafana/ui';
import { ManagerKind } from 'app/features/apiserver/types';
import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView';
import { useIsProvisionedInstance } from 'app/features/provisioning/hooks/useIsProvisionedInstance';
import { getReadOnlyTooltipText } from 'app/features/provisioning/utils/repository';
import { NestedFolderDTO } from 'app/features/search/service/types';
import { FolderDTO, FolderListItemDTO } from 'app/types/folders';
import { DashboardViewItem } from 'app/features/search/types';
import { FolderDTO } from 'app/types/folders';
export interface Props {
folder?: FolderListItemDTO | NestedFolderDTO | FolderDTO;
folder?: FolderDTO | DashboardViewItem;
}
export function FolderRepo({ folder }: Props) {
@@ -17,11 +18,11 @@ export function FolderRepo({ folder }: Props) {
// folder is not managed
// if whole instance is provisioned
const isProvisionedInstance = useIsProvisionedInstance();
const skipRender =
!folder || ('parentUID' in folder && folder.parentUID) || !folder.managedBy || isProvisionedInstance;
const skipRender = getShouldSkipRender(folder, isProvisionedInstance);
const { isReadOnlyRepo, repoType } = useGetResourceRepositoryView({
folderName: skipRender ? undefined : folder?.uid,
skipQuery: skipRender,
});
if (skipRender) {
@@ -38,7 +39,21 @@ export function FolderRepo({ folder }: Props) {
tooltip={getReadOnlyTooltipText({ isLocal: repoType === 'local' })}
/>
)}
<Badge color="purple" icon="exchange-alt" tooltip={t('folder-repo.provisioned-badge', 'Provisioned')} />
<Badge
title={t('folder-repo.provisioned-badge', 'Provisioned')}
color="purple"
icon="exchange-alt"
tooltip={t('folder-repo.provisioned-badge', 'Provisioned')}
/>
</Stack>
);
}
function getShouldSkipRender(folder: FolderDTO | DashboardViewItem | undefined, isProvisionedInstance?: boolean) {
// Skip render if parentUID is present, then we should skip rendering. we only display icon for root folders
const hasParent = folder && Boolean('parentUID' in folder && folder.parentUID);
// Skip render if folder is not managed by Repo
const isNotManaged = folder && folder.managedBy !== ManagerKind.Repo;
return !folder || hasParent || isNotManaged || isProvisionedInstance;
}
@@ -1,10 +1,12 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { useCallback, useMemo } from 'react';
import { OrgRole } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { CallToActionCard, EmptyState, LinkButton, TextLink } from '@grafana/ui';
import { useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1';
import { contextSrv } from 'app/core/core';
import { useIsProvisionedInstance } from 'app/features/provisioning/hooks/useIsProvisionedInstance';
import { DashboardViewItem } from 'app/features/search/types';
import { useDispatch, useSelector } from 'app/types/store';
@@ -41,7 +43,8 @@ export function BrowseView({ folderUID, width, height, permissions }: BrowseView
const canSelect = canSelectItems(permissions);
const isProvisionedInstance = useIsProvisionedInstance();
const provisioningEnabled = config.featureToggles.provisioning;
const { data: settingsData } = useGetFrontendSettingsQuery(!provisioningEnabled ? skipToken : undefined);
const hasNoRole = contextSrv.user.orgRole === OrgRole.None;
const { data: settingsData } = useGetFrontendSettingsQuery(!provisioningEnabled || hasNoRole ? skipToken : undefined);
const rootItems = useSelector(rootItemsSelector);
const excludeUIDs = useMemo(() => {
@@ -1,8 +1,10 @@
import { skipToken } from '@reduxjs/toolkit/query/react';
import { OrgRole } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Folder, useGetFolderQuery } from 'app/api/clients/folder/v1beta1';
import { RepositoryView, useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1';
import { contextSrv } from 'app/core/core';
import { AnnoKeyManagerIdentity } from 'app/features/apiserver/types';
import { RepoType } from '../Wizard/types';
@@ -11,6 +13,7 @@ import { getIsReadOnlyRepo } from '../utils/repository';
interface GetResourceRepositoryArgs {
name?: string; // the repository name
folderName?: string; // folder we are targeting
skipQuery?: boolean;
}
interface RepositoryViewData {
@@ -23,13 +26,19 @@ interface RepositoryViewData {
}
// This is safe to call as a viewer (you do not need full access to the Repository configs)
export const useGetResourceRepositoryView = ({ name, folderName }: GetResourceRepositoryArgs): RepositoryViewData => {
export const useGetResourceRepositoryView = ({
name,
folderName,
skipQuery,
}: GetResourceRepositoryArgs): RepositoryViewData => {
const hasNoRole = contextSrv.user.orgRole === OrgRole.None;
const provisioningEnabled = config.featureToggles.provisioning;
const { data: settingsData, isLoading: isSettingsLoading } = useGetFrontendSettingsQuery(
!provisioningEnabled ? skipToken : undefined
!provisioningEnabled || skipQuery || hasNoRole ? skipToken : undefined
);
const skipFolderQuery = !folderName || !provisioningEnabled;
const skipFolderQuery = !folderName || !provisioningEnabled || skipQuery || hasNoRole;
const { data: folder, isLoading: isFolderLoading } = useGetFolderQuery(
skipFolderQuery ? skipToken : { name: folderName }
);
@@ -1,12 +1,15 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { OrgRole } from '@grafana/data';
import { config } from '@grafana/runtime';
import { RepositoryViewList, useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1';
import { contextSrv } from 'app/core/core';
export function useIsProvisionedInstance(settings?: RepositoryViewList) {
const settingsQuery = useGetFrontendSettingsQuery(
settings || !config.featureToggles.provisioning ? skipToken : undefined
);
const hasNoRole = contextSrv.user.orgRole === OrgRole.None;
const skip = !config.featureToggles.provisioning || hasNoRole;
const settingsQuery = useGetFrontendSettingsQuery(settings || skip ? skipToken : undefined);
if (!settings) {
settings = settingsQuery.data;
}