tourStepsInfo[1]?.tourTargetRef?.current || document.body}
- content={{tourStepsInfo[1].content}
}
- isStepOpen={tourState.isTourActive && tourState.currentTourStep === 2}
- maxWidth={tourState.tourPopoverWidth}
- onFinish={finishTour}
- step={1}
- stepsTotal={(queryRuleset?.rules?.length ?? 0) > 1 ? 2 : 1}
- title={
-
- {tourStepsInfo[1].title}
-
- }
- anchorPosition="downLeft"
- zIndex={1}
- footerAction={
-
-
-
- {i18n.translate('xpack.queryRules.queryRulesetDetail.backTourButton', {
- defaultMessage: 'Back',
- })}
-
-
-
-
- {i18n.translate('xpack.queryRules.queryRulesetDetail.closeTourButton', {
- defaultMessage: 'Close tour',
- })}
-
-
-
- }
+ {(!blockRender && !isFailsafeLoading && isError && createMode) ||
+ (!isError && (
+ <>
+
- )}
- >
- )}
- {rulesetToDelete && (
+
+ {tourStepsInfo[1]?.tourTargetRef?.current !== null && (
+ tourStepsInfo[1]?.tourTargetRef?.current || document.body}
+ content={{tourStepsInfo[1].content}
}
+ isStepOpen={tourState.isTourActive && tourState.currentTourStep === 2}
+ maxWidth={tourState.tourPopoverWidth}
+ onFinish={finishTour}
+ step={1}
+ stepsTotal={(queryRuleset?.rules?.length ?? 0) > 1 ? 2 : 1}
+ title={
+
+ {tourStepsInfo[1].title}
+
+ }
+ anchorPosition="downLeft"
+ zIndex={1}
+ footerAction={
+
+
+
+ {i18n.translate('xpack.queryRules.queryRulesetDetail.backTourButton', {
+ defaultMessage: 'Back',
+ })}
+
+
+
+
+ {i18n.translate('xpack.queryRules.queryRulesetDetail.closeTourButton', {
+ defaultMessage: 'Close tour',
+ })}
+
+
+
+ }
+ />
+ )}
+ >
+ ))}
+ {!blockRender && rulesetToDelete && (
{
@@ -427,7 +450,7 @@ export const QueryRulesetDetail: React.FC = () => {
}}
/>
)}
- {isError && error && (
+ {!blockRender && isError && !createMode && error && (
({
+ ruleset_id: rulesetId,
+ rules: [],
+});
+
interface UseQueryRulesetDetailStateProps {
rulesetId: string;
+ createMode: boolean;
}
-export const useQueryRulesetDetailState = ({ rulesetId }: UseQueryRulesetDetailStateProps) => {
- const { data, isInitialLoading, isError, error } = useFetchQueryRuleset(rulesetId);
- const [queryRuleset, setQueryRuleset] = useState(null);
+export const useQueryRulesetDetailState = ({
+ rulesetId,
+ createMode,
+}: UseQueryRulesetDetailStateProps) => {
+ const { data, isInitialLoading, isError, error } = useFetchQueryRuleset(rulesetId, !createMode);
+ const [queryRuleset, setQueryRuleset] = useState(
+ createMode ? createEmptyRuleset(rulesetId) : null
+ );
const [rules, setRules] = useState([]);
useEffect(() => {
- if (data) {
+ if (!createMode && !isError && data) {
const normalizedRuleset = normalizeQueryRuleset(data);
setQueryRuleset(normalizedRuleset);
setRules(normalizedRuleset.rules);
}
- }, [data, setRules, setQueryRuleset]);
+ }, [data, setRules, setQueryRuleset, createMode, isError]);
const updateRule = (updatedRule: SearchQueryRulesQueryRule) => {
const newRules = rules.map((rule) =>
@@ -34,16 +47,12 @@ export const useQueryRulesetDetailState = ({ rulesetId }: UseQueryRulesetDetailS
);
setRules([...newRules]);
};
- const addNewRule = (newRuleId: string) => {
+
+ const addNewRule = (newRule: SearchQueryRulesQueryRule) => {
setRules((prevRules) => [
...prevRules,
{
- rule_id: newRuleId,
- criteria: [],
- type: 'pinned',
- actions: {
- docs: [],
- },
+ ...newRule,
},
]);
};
diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_query_ruleset.ts b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_query_ruleset.ts
index bb111f7c501e0..793f0a3605672 100644
--- a/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_query_ruleset.ts
+++ b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_query_ruleset.ts
@@ -11,7 +11,7 @@ import { QueryRulesQueryRuleset } from '@elastic/elasticsearch/lib/api/types';
import { QUERY_RULES_QUERY_RULESET_FETCH_KEY } from '../../common/constants';
import { useKibana } from './use_kibana';
-export const useFetchQueryRuleset = (rulesetId: string) => {
+export const useFetchQueryRuleset = (rulesetId: string, enabled = true) => {
const {
services: { http },
} = useKibana();
@@ -24,5 +24,6 @@ export const useFetchQueryRuleset = (rulesetId: string) => {
);
},
retry: false,
+ enabled,
});
};
diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_ruleset_exists.test.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_ruleset_exists.test.tsx
new file mode 100644
index 0000000000000..5da58f9bb119a
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_ruleset_exists.test.tsx
@@ -0,0 +1,79 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { renderHook, waitFor } from '@testing-library/react';
+
+const mockHttpGet = jest.fn();
+
+jest.mock('./use_kibana', () => ({
+ useKibana: jest.fn().mockReturnValue({
+ services: {
+ http: {
+ get: mockHttpGet,
+ },
+ },
+ }),
+}));
+
+describe('useFetchQueryRulesetExist Hook', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const wrapper = ({ children }: { children: React.ReactNode }) => {
+ const queryClient = new QueryClient();
+ return {children};
+ };
+
+ it('should run onNoConflict when ruleset does not exist', async () => {
+ const { useFetchQueryRulesetExist } = jest.requireActual('./use_fetch_ruleset_exists');
+ const onNoConflict = jest.fn();
+ const onConflict = jest.fn();
+
+ mockHttpGet.mockResolvedValue({ exists: false });
+ const { result } = renderHook(
+ () => useFetchQueryRulesetExist('non-existent-ruleset', onNoConflict, onConflict),
+ { wrapper }
+ );
+
+ await waitFor(() =>
+ expect(mockHttpGet).toHaveBeenCalledWith(
+ '/internal/search_query_rules/ruleset/non-existent-ruleset/exists'
+ )
+ );
+
+ await waitFor(() => expect(result.current.data).toBe(false));
+ await waitFor(() => expect(onNoConflict).toHaveBeenCalled());
+ await waitFor(() => expect(onConflict).not.toHaveBeenCalled());
+ });
+
+ it('should run onConflict when ruleset exists', async () => {
+ const { useFetchQueryRulesetExist } = jest.requireActual('./use_fetch_ruleset_exists');
+ const onNoConflict = jest.fn();
+ const onConflict = jest.fn();
+
+ mockHttpGet.mockResolvedValue({ exists: true });
+
+ const { result } = renderHook(
+ () => useFetchQueryRulesetExist('existing-ruleset', onNoConflict, onConflict),
+ { wrapper }
+ );
+
+ await waitFor(() =>
+ expect(mockHttpGet).toHaveBeenCalledWith(
+ '/internal/search_query_rules/ruleset/existing-ruleset/exists'
+ )
+ );
+
+ await waitFor(() => expect(result.current.data).toBe(true));
+ await waitFor(() => expect(onNoConflict).not.toHaveBeenCalled());
+ await waitFor(() => expect(onConflict).toHaveBeenCalled());
+ });
+});
diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_ruleset_exists.ts b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_ruleset_exists.ts
new file mode 100644
index 0000000000000..fc5d6de1cd5d9
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_fetch_ruleset_exists.ts
@@ -0,0 +1,41 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { useQuery } from '@tanstack/react-query';
+import { KibanaServerError } from '@kbn/kibana-utils-plugin/common';
+import { QUERY_RULES_QUERY_RULESET_EXISTS_KEY } from '../../common/constants';
+import { useKibana } from './use_kibana';
+
+export const useFetchQueryRulesetExist = (
+ rulesetId: string,
+ onNoConflict?: () => void,
+ onConflict?: () => void
+) => {
+ const {
+ services: { http },
+ } = useKibana();
+
+ return useQuery({
+ queryKey: [QUERY_RULES_QUERY_RULESET_EXISTS_KEY, rulesetId],
+ queryFn: async () => {
+ const { exists } = await http.get<{ exists: boolean }>(
+ `/internal/search_query_rules/ruleset/${rulesetId}/exists`
+ );
+ if (!exists && onNoConflict) {
+ onNoConflict();
+ }
+ if (exists && onConflict) {
+ onConflict();
+ }
+
+ return exists;
+ },
+ retry: false,
+ refetchOnWindowFocus: false,
+ enabled: !!rulesetId,
+ });
+};
diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_put_query_rules_ruleset.ts b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_put_query_rules_ruleset.ts
index c229d85f4e9ae..e7bbf6fe9785a 100644
--- a/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_put_query_rules_ruleset.ts
+++ b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_put_query_rules_ruleset.ts
@@ -10,6 +10,7 @@ import { QueryRulesQueryRuleset } from '@elastic/elasticsearch/lib/api/types';
import { i18n } from '@kbn/i18n';
import { KibanaServerError } from '@kbn/kibana-utils-plugin/common';
import {
+ QUERY_RULES_QUERY_RULESET_EXISTS_KEY,
QUERY_RULES_QUERY_RULESET_FETCH_KEY,
QUERY_RULES_SETS_QUERY_KEY,
} from '../../common/constants';
@@ -43,8 +44,9 @@ export const usePutRuleset = (
},
{
onSuccess: (_, { rulesetId }) => {
- queryClient.invalidateQueries([QUERY_RULES_QUERY_RULESET_FETCH_KEY]);
- queryClient.invalidateQueries([QUERY_RULES_SETS_QUERY_KEY]);
+ queryClient.invalidateQueries({ queryKey: [QUERY_RULES_QUERY_RULESET_FETCH_KEY] });
+ queryClient.invalidateQueries({ queryKey: [QUERY_RULES_SETS_QUERY_KEY] });
+ queryClient.invalidateQueries({ queryKey: [QUERY_RULES_QUERY_RULESET_EXISTS_KEY] });
notifications?.toasts?.addSuccess({
title: i18n.translate('xpack.queryRules.putRulesetSuccess', {
defaultMessage: 'Ruleset added',
diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_run_query_ruleset.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_run_query_ruleset.tsx
index 4fdaa4e26b87b..f8d9254402fa7 100644
--- a/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_run_query_ruleset.tsx
+++ b/x-pack/solutions/search/plugins/search_query_rules/public/hooks/use_run_query_ruleset.tsx
@@ -17,6 +17,7 @@ export interface UseRunQueryRulesetProps {
content?: string;
color?: EuiButtonColor;
onClick?: () => void;
+ disabled?: boolean;
}
export const UseRunQueryRuleset = ({
@@ -25,9 +26,10 @@ export const UseRunQueryRuleset = ({
content,
color,
onClick,
+ disabled = false,
}: UseRunQueryRulesetProps) => {
const { application, share, console: consolePlugin } = useKibana().services;
- const { data: queryRulesetData } = useFetchQueryRuleset(rulesetId);
+ const { data: queryRulesetData } = useFetchQueryRuleset(rulesetId, !disabled);
// Loop through all actions children to gather unique _index values
const { indices, matchCriteria } = useMemo((): { indices: string; matchCriteria: string } => {
diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/routes.ts b/x-pack/solutions/search/plugins/search_query_rules/public/routes.ts
index 0e0899b6ab448..6fae0ea1792ae 100644
--- a/x-pack/solutions/search/plugins/search_query_rules/public/routes.ts
+++ b/x-pack/solutions/search/plugins/search_query_rules/public/routes.ts
@@ -7,3 +7,4 @@
export const ROOT_PATH = '/';
export const QUERY_RULESET_DETAIL_PATH = `${ROOT_PATH}ruleset/:rulesetId`;
+export const CREATE_QUERY_RULESET_PATH = `${ROOT_PATH}ruleset/:rulesetId/create`;
diff --git a/x-pack/solutions/search/plugins/search_query_rules/public/search_query_router.tsx b/x-pack/solutions/search/plugins/search_query_rules/public/search_query_router.tsx
index d0c4693829fd4..47064f0077789 100644
--- a/x-pack/solutions/search/plugins/search_query_rules/public/search_query_router.tsx
+++ b/x-pack/solutions/search/plugins/search_query_rules/public/search_query_router.tsx
@@ -7,7 +7,7 @@
import { Route, Routes } from '@kbn/shared-ux-router';
import React from 'react';
-import { QUERY_RULESET_DETAIL_PATH, ROOT_PATH } from './routes';
+import { CREATE_QUERY_RULESET_PATH, QUERY_RULESET_DETAIL_PATH, ROOT_PATH } from './routes';
import { QueryRulesOverview } from './components/overview/overview';
import { QueryRulesetDetail } from './components/query_ruleset_detail/query_ruleset_detail';
@@ -17,6 +17,9 @@ export const QueryRulesRouter = () => {
+
+
+
diff --git a/x-pack/solutions/search/plugins/search_query_rules/server/routes.ts b/x-pack/solutions/search/plugins/search_query_rules/server/routes.ts
index e8ccbfd6d3c01..b93e6553a44fd 100644
--- a/x-pack/solutions/search/plugins/search_query_rules/server/routes.ts
+++ b/x-pack/solutions/search/plugins/search_query_rules/server/routes.ts
@@ -21,6 +21,7 @@ import { fetchQueryRulesSets } from './lib/fetch_query_rules_sets';
import { isQueryRulesetExist } from './lib/is_query_ruleset_exist';
import { putRuleset } from './lib/put_query_rules_ruleset_set';
import { errorHandler } from './utils/error_handler';
+import { checkPrivileges } from './utils/privilege_check';
export function defineRoutes({ logger, router }: { logger: Logger; router: IRouter }) {
router.get(
@@ -112,16 +113,18 @@ export function defineRoutes({ logger, router }: { logger: Logger; router: IRout
}
const rulesetData = await fetchQueryRulesRuleset(asCurrentUser, request.params.ruleset_id);
+ if (!rulesetData) {
+ return response.notFound({
+ body: i18n.translate('xpack.search.rules.api.routes.rulesetNotFoundErrorMessage', {
+ defaultMessage: 'Ruleset not found',
+ }),
+ });
+ }
return response.ok({
headers: {
'content-type': 'application/json',
},
- body:
- rulesetData ??
- response.customError({
- statusCode: 404,
- body: 'Ruleset not found',
- }),
+ body: rulesetData,
});
})
);
@@ -154,8 +157,8 @@ export function defineRoutes({ logger, router }: { logger: Logger; router: IRout
criteria: schema.arrayOf(
schema.object({
type: schema.string(),
- metadata: schema.string(),
- values: schema.arrayOf(schema.string()),
+ metadata: schema.maybe(schema.string()),
+ values: schema.maybe(schema.arrayOf(schema.string())),
})
),
actions: schema.object({
@@ -218,6 +221,43 @@ export function defineRoutes({ logger, router }: { logger: Logger; router: IRout
});
})
);
+ router.get(
+ {
+ path: APIRoutes.QUERY_RULES_RULESET_EXISTS,
+ options: {
+ access: 'internal',
+ },
+ security: {
+ authz: {
+ requiredPrivileges: ['manage_search_query_rules'],
+ },
+ },
+ validate: {
+ params: schema.object({
+ rulesetId: schema.string(),
+ }),
+ },
+ },
+ errorHandler(logger)(async (context, request, response) => {
+ const { rulesetId } = request.params;
+ const core = await context.core;
+ const {
+ client: { asCurrentUser },
+ } = core.elasticsearch;
+
+ await checkPrivileges(core, response);
+
+ const isExisting = await isQueryRulesetExist(asCurrentUser, rulesetId);
+
+ return response.ok({
+ headers: {
+ 'content-type': 'application/json',
+ },
+ body: { exists: isExisting },
+ });
+ })
+ );
+
router.delete(
{
path: APIRoutes.QUERY_RULES_RULESET_ID,
diff --git a/x-pack/solutions/search/plugins/search_query_rules/server/utils/privilege_check.test.ts b/x-pack/solutions/search/plugins/search_query_rules/server/utils/privilege_check.test.ts
new file mode 100644
index 0000000000000..3d8c128c7edb9
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_query_rules/server/utils/privilege_check.test.ts
@@ -0,0 +1,72 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { CoreRequestHandlerContext, KibanaResponseFactory } from '@kbn/core/server';
+import { checkPrivileges } from './privilege_check';
+
+const MOCK_CORE = {
+ elasticsearch: {
+ client: {
+ asCurrentUser: {
+ security: {
+ hasPrivileges: jest.fn().mockResolvedValue({ has_all_requested: false }),
+ },
+ },
+ },
+ },
+ security: {
+ authc: {
+ getCurrentUser: jest.fn().mockReturnValue(null),
+ },
+ },
+} as unknown as CoreRequestHandlerContext;
+
+const MOCK_RESPONSE = {
+ customError: jest.fn(),
+ forbidden: jest.fn(),
+} as unknown as KibanaResponseFactory;
+
+describe('privilege check util', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should return 502 error if user is not available', async () => {
+ const mockCore = { ...MOCK_CORE };
+ const mockResponse = { ...MOCK_RESPONSE };
+ await checkPrivileges(mockCore, mockResponse);
+
+ expect(mockResponse.customError).toHaveBeenCalledWith({
+ statusCode: 502,
+ body: expect.stringContaining(
+ 'Could not retrieve current user, security plugin is not ready'
+ ),
+ });
+ });
+ it('should return forbidden error if user does not have required privileges', async () => {
+ const mockCore = { ...MOCK_CORE };
+ mockCore.security.authc.getCurrentUser = jest.fn().mockReturnValue({ username: 'test_user' });
+ const mockResponse = { ...MOCK_RESPONSE };
+ await checkPrivileges(mockCore, mockResponse);
+
+ expect(mockResponse.forbidden).toHaveBeenCalledWith({
+ body: "You don't have manage_search_query_rules privileges",
+ });
+ });
+ it('should not return an error if all checks are passed', async () => {
+ const mockCore = { ...MOCK_CORE };
+ mockCore.security.authc.getCurrentUser = jest.fn().mockReturnValue({ username: 'test_user' });
+ mockCore.elasticsearch.client.asCurrentUser.security.hasPrivileges = jest
+ .fn()
+ .mockResolvedValue({ has_all_requested: true });
+
+ const mockResponse = { ...MOCK_RESPONSE };
+ await checkPrivileges(mockCore, mockResponse);
+ expect(mockResponse.forbidden).not.toHaveBeenCalled();
+ expect(mockResponse.customError).not.toHaveBeenCalled();
+ });
+});
diff --git a/x-pack/solutions/search/plugins/search_query_rules/server/utils/privilege_check.ts b/x-pack/solutions/search/plugins/search_query_rules/server/utils/privilege_check.ts
new file mode 100644
index 0000000000000..3501493dd4346
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_query_rules/server/utils/privilege_check.ts
@@ -0,0 +1,34 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { CoreRequestHandlerContext, KibanaResponseFactory } from '@kbn/core/server';
+import { i18n } from '@kbn/i18n';
+
+export const checkPrivileges = async (
+ core: CoreRequestHandlerContext,
+ response: KibanaResponseFactory
+) => {
+ const user = core.security?.authc.getCurrentUser();
+ if (!user) {
+ return response.customError({
+ statusCode: 502,
+ body: i18n.translate('xpack.search.queryRules.api.routes.noUserError', {
+ defaultMessage: 'Could not retrieve current user, security plugin is not ready',
+ }),
+ });
+ }
+ const hasPrivilege = await core.elasticsearch.client.asCurrentUser.security.hasPrivileges({
+ cluster: ['manage_search_query_rules'],
+ });
+ if (!hasPrivilege.has_all_requested) {
+ response.forbidden({
+ body: i18n.translate('xpack.search.queryRules.api.routes.permissionError', {
+ defaultMessage: "You don't have manage_search_query_rules privileges",
+ }),
+ });
+ }
+};