diff --git a/x-pack/plugins/fleet/common/constants/routes.ts b/x-pack/plugins/fleet/common/constants/routes.ts index be4103c549f1a..235a1c5bd85e5 100644 --- a/x-pack/plugins/fleet/common/constants/routes.ts +++ b/x-pack/plugins/fleet/common/constants/routes.ts @@ -65,6 +65,8 @@ export const OUTPUT_API_ROUTES = { LIST_PATTERN: `${API_ROOT}/outputs`, INFO_PATTERN: `${API_ROOT}/outputs/{outputId}`, UPDATE_PATTERN: `${API_ROOT}/outputs/{outputId}`, + DELETE_PATTERN: `${API_ROOT}/outputs/{outputId}`, + CREATE_PATTERN: `${API_ROOT}/outputs`, }; // Settings API routes diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/output.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/output.yaml index b4e060ca0c151..e695f0048e6ad 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/output.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/output.yaml @@ -5,6 +5,8 @@ properties: type: string is_default: type: boolean + is_default_monitoring: + type: boolean name: type: string type: diff --git a/x-pack/plugins/fleet/common/openapi/paths/outputs.yaml b/x-pack/plugins/fleet/common/openapi/paths/outputs.yaml index 94fe7c16e520d..6425149bd81f1 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/outputs.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/outputs.yaml @@ -20,3 +20,46 @@ get: perPage: type: integer operationId: get-outputs +post: + summary: Outputs + description: 'Create a new output' + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + item: + $ref: ../components/schemas/output.yaml + requestBody: + content: + application/json: + schema: + type: object + properties: + id: + type: string + name: + type: string + type: + type: string + enum: ["elasticsearch"] + is_default: + type: boolean + is_default_monitoring: + type: boolean + hosts: + type: array + items: + type: string + ca_sha256: + type: string + config_yaml: + type: string + required: + - name + - type + operationId: post-outputs diff --git a/x-pack/plugins/fleet/common/openapi/paths/outputs@{output_id}.yaml b/x-pack/plugins/fleet/common/openapi/paths/outputs@{output_id}.yaml index 2f8f5e76ebaff..326a65692a03b 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/outputs@{output_id}.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/outputs@{output_id}.yaml @@ -20,6 +20,23 @@ parameters: name: outputId in: path required: true +delete: + summary: Output - Delete + operationId: delete-output + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + id: + type: string + required: + - id + parameters: + - $ref: ../components/headers/kbn_xsrf.yaml put: summary: Output - Update operationId: update-output @@ -29,14 +46,26 @@ put: schema: type: object properties: - hosts: + name: + type: string + type: type: string + enum: ["elasticsearch"] + is_default: + type: boolean + is_default_monitoring: + type: boolean + hosts: + type: array + items: + type: string ca_sha256: type: string - config: - type: object config_yaml: type: string + required: + - name + - type responses: '200': description: OK diff --git a/x-pack/plugins/fleet/common/types/rest_spec/output.ts b/x-pack/plugins/fleet/common/types/rest_spec/output.ts index ef3c2f9f998ca..f8eb20b51f208 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/output.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/output.ts @@ -11,6 +11,10 @@ export interface GetOneOutputResponse { item: Output; } +export interface DeleteOutputResponse { + id: string; +} + export interface GetOneOutputRequest { params: { outputId: string; diff --git a/x-pack/plugins/fleet/server/errors/index.ts b/x-pack/plugins/fleet/server/errors/index.ts index 6075b7e441fdf..b2039dad4d57c 100644 --- a/x-pack/plugins/fleet/server/errors/index.ts +++ b/x-pack/plugins/fleet/server/errors/index.ts @@ -53,6 +53,8 @@ export class HostedAgentPolicyRestrictionRelatedError extends IngestManagerError export class FleetSetupError extends IngestManagerError {} export class GenerateServiceTokenError extends IngestManagerError {} +export class OutputUnauthorizedError extends IngestManagerError {} + export class ArtifactsClientError extends IngestManagerError {} export class ArtifactsClientAccessDeniedError extends IngestManagerError { constructor(deniedPackageName: string, allowedPackageName: string) { diff --git a/x-pack/plugins/fleet/server/routes/output/handler.ts b/x-pack/plugins/fleet/server/routes/output/handler.ts index 0c56d55423e4b..98b42775ae223 100644 --- a/x-pack/plugins/fleet/server/routes/output/handler.ts +++ b/x-pack/plugins/fleet/server/routes/output/handler.ts @@ -8,8 +8,17 @@ import type { RequestHandler } from 'src/core/server'; import type { TypeOf } from '@kbn/config-schema'; -import type { GetOneOutputRequestSchema, PutOutputRequestSchema } from '../../types'; -import type { GetOneOutputResponse, GetOutputsResponse } from '../../../common'; +import type { + DeleteOutputRequestSchema, + GetOneOutputRequestSchema, + PostOutputRequestSchema, + PutOutputRequestSchema, +} from '../../types'; +import type { + DeleteOutputResponse, + GetOneOutputResponse, + GetOutputsResponse, +} from '../../../common'; import { outputService } from '../../services/output'; import { defaultIngestErrorHandler } from '../../errors'; @@ -78,3 +87,45 @@ export const putOuputHandler: RequestHandler< return defaultIngestErrorHandler({ error, response }); } }; + +export const postOuputHandler: RequestHandler< + undefined, + undefined, + TypeOf +> = async (context, request, response) => { + const soClient = context.core.savedObjects.client; + try { + const { id, ...data } = request.body; + const output = await outputService.create(soClient, data, { id }); + + const body: GetOneOutputResponse = { + item: output, + }; + + return response.ok({ body }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); + } +}; + +export const deleteOutputHandler: RequestHandler> = + async (context, request, response) => { + const soClient = context.core.savedObjects.client; + try { + await outputService.delete(soClient, request.params.outputId); + + const body: DeleteOutputResponse = { + id: request.params.outputId, + }; + + return response.ok({ body }); + } catch (error) { + if (error.isBoom && error.output.statusCode === 404) { + return response.notFound({ + body: { message: `Output ${request.params.outputId} not found` }, + }); + } + + return defaultIngestErrorHandler({ error, response }); + } + }; diff --git a/x-pack/plugins/fleet/server/routes/output/index.ts b/x-pack/plugins/fleet/server/routes/output/index.ts index 5bdbfc7387414..9ef8bab6ea408 100644 --- a/x-pack/plugins/fleet/server/routes/output/index.ts +++ b/x-pack/plugins/fleet/server/routes/output/index.ts @@ -9,12 +9,20 @@ import type { IRouter } from 'src/core/server'; import { PLUGIN_ID, OUTPUT_API_ROUTES } from '../../constants'; import { + DeleteOutputRequestSchema, GetOneOutputRequestSchema, GetOutputsRequestSchema, + PostOutputRequestSchema, PutOutputRequestSchema, } from '../../types'; -import { getOneOuputHandler, getOutputsHandler, putOuputHandler } from './handler'; +import { + deleteOutputHandler, + getOneOuputHandler, + getOutputsHandler, + postOuputHandler, + putOuputHandler, +} from './handler'; export const registerRoutes = (router: IRouter) => { router.get( @@ -37,8 +45,26 @@ export const registerRoutes = (router: IRouter) => { { path: OUTPUT_API_ROUTES.UPDATE_PATTERN, validate: PutOutputRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-read`] }, + options: { tags: [`access:${PLUGIN_ID}-all`] }, }, putOuputHandler ); + + router.post( + { + path: OUTPUT_API_ROUTES.CREATE_PATTERN, + validate: PostOutputRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-all`] }, + }, + postOuputHandler + ); + + router.delete( + { + path: OUTPUT_API_ROUTES.DELETE_PATTERN, + validate: DeleteOutputRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-all`] }, + }, + deleteOutputHandler + ); }; diff --git a/x-pack/plugins/fleet/server/services/output.ts b/x-pack/plugins/fleet/server/services/output.ts index e39f70671a232..0626caa37df9a 100644 --- a/x-pack/plugins/fleet/server/services/output.ts +++ b/x-pack/plugins/fleet/server/services/output.ts @@ -11,6 +11,7 @@ import uuid from 'uuid/v5'; import type { NewOutput, Output, OutputSOAttributes } from '../types'; import { DEFAULT_OUTPUT, OUTPUT_SAVED_OBJECT_TYPE } from '../constants'; import { decodeCloudId, normalizeHostsForAgents, SO_SEARCH_LIMIT } from '../../common'; +import { OutputUnauthorizedError } from '../errors'; import { appContextService } from './app_context'; @@ -222,10 +223,19 @@ class OutputService { const originalOutput = await this.get(soClient, id); if (originalOutput.is_preconfigured && !fromPreconfiguration) { - throw new Error( + throw new OutputUnauthorizedError( `Preconfigured output ${id} cannot be deleted outside of kibana config file.` ); } + + if (originalOutput.is_default && !fromPreconfiguration) { + throw new OutputUnauthorizedError(`Default output ${id} cannot be deleted.`); + } + + if (originalOutput.is_default_monitoring && !fromPreconfiguration) { + throw new OutputUnauthorizedError(`Default monitoring output ${id} cannot be deleted.`); + } + return soClient.delete(SAVED_OBJECT_TYPE, outputIdToUuid(id)); } @@ -240,7 +250,7 @@ class OutputService { const originalOutput = await this.get(soClient, id); if (originalOutput.is_preconfigured && !fromPreconfiguration) { - throw new Error( + throw new OutputUnauthorizedError( `Preconfigured output ${id} cannot be updated outside of kibana config file.` ); } diff --git a/x-pack/plugins/fleet/server/types/rest_spec/output.ts b/x-pack/plugins/fleet/server/types/rest_spec/output.ts index 5e6cc37e6d459..05a307009d527 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/output.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/output.ts @@ -13,16 +13,37 @@ export const GetOneOutputRequestSchema = { }), }; +export const DeleteOutputRequestSchema = { + params: schema.object({ + outputId: schema.string(), + }), +}; + export const GetOutputsRequestSchema = {}; +export const PostOutputRequestSchema = { + body: schema.object({ + id: schema.maybe(schema.string()), + name: schema.string(), + type: schema.oneOf([schema.literal('elasticsearch')]), + is_default: schema.boolean({ defaultValue: false }), + is_default_monitoring: schema.boolean({ defaultValue: false }), + hosts: schema.maybe(schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }))), + ca_sha256: schema.maybe(schema.string()), + config_yaml: schema.maybe(schema.string()), + }), +}; + export const PutOutputRequestSchema = { params: schema.object({ outputId: schema.string(), }), body: schema.object({ + name: schema.maybe(schema.string()), + is_default: schema.maybe(schema.boolean()), + is_default_monitoring: schema.maybe(schema.boolean()), hosts: schema.maybe(schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }))), ca_sha256: schema.maybe(schema.string()), - config: schema.maybe(schema.recordOf(schema.string(), schema.any())), config_yaml: schema.maybe(schema.string()), }), }; diff --git a/x-pack/test/fleet_api_integration/apis/outputs/crud.ts b/x-pack/test/fleet_api_integration/apis/outputs/crud.ts index 521675b87fb11..86ce4a8fdf617 100644 --- a/x-pack/test/fleet_api_integration/apis/outputs/crud.ts +++ b/x-pack/test/fleet_api_integration/apis/outputs/crud.ts @@ -39,31 +39,205 @@ export default function (providerContext: FtrProviderContext) { await esArchiver.unload('x-pack/test/functional/es_archives/fleet/empty_fleet_server'); }); - it('GET /outputs should list the default output', async () => { - const { body: getOutputsRes } = await supertest.get(`/api/fleet/outputs`).expect(200); + describe('GET /outputs', () => { + it('should list the default output', async () => { + const { body: getOutputsRes } = await supertest.get(`/api/fleet/outputs`).expect(200); + + expect(getOutputsRes.items.length).to.eql(1); + }); + }); + + describe('GET /outputs/{outputId}', () => { + it('should allow return the default output', async () => { + const { body: getOutputRes } = await supertest + .get(`/api/fleet/outputs/${defaultOutputId}`) + .expect(200); + + expect(getOutputRes.item).to.have.keys('id', 'name', 'type', 'is_default', 'hosts'); + }); + }); - expect(getOutputsRes.items.length).to.eql(1); + describe('PUT /outputs/{outputId}', () => { + it('should explicitly set port on ES hosts', async function () { + await supertest + .put(`/api/fleet/outputs/${defaultOutputId}`) + .set('kbn-xsrf', 'xxxx') + .send({ hosts: ['https://test.fr'] }) + .expect(200); + + const { + body: { item: output }, + } = await supertest.get(`/api/fleet/outputs/${defaultOutputId}`).expect(200); + + expect(output.hosts).to.eql(['https://test.fr:443']); + }); + + it('should return a 404 when updating a non existing output', async function () { + await supertest + .put(`/api/fleet/outputs/idonotexists`) + .set('kbn-xsrf', 'xxxx') + .send({ hosts: ['https://test.fr'] }) + .expect(404); + }); }); - it('GET /outputs/{defaultOutputId} should return the default output', async () => { - const { body: getOutputRes } = await supertest - .get(`/api/fleet/outputs/${defaultOutputId}`) - .expect(200); + describe('POST /outputs', () => { + it('should allow to create an output ', async function () { + const { body: postResponse } = await supertest + .post(`/api/fleet/outputs`) + .set('kbn-xsrf', 'xxxx') + .send({ name: 'My output', type: 'elasticsearch', hosts: ['https://test.fr'] }) + .expect(200); + + const { id: _, ...itemWithoutId } = postResponse.item; + expect(itemWithoutId).to.eql({ + name: 'My output', + type: 'elasticsearch', + hosts: ['https://test.fr:443'], + is_default: false, + is_default_monitoring: false, + }); + }); + + it('should toggle default output when creating a new default output ', async function () { + await supertest + .post(`/api/fleet/outputs`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'default output 1', + type: 'elasticsearch', + hosts: ['https://test.fr'], + is_default: true, + }) + .expect(200); + + const { + body: { item: output2 }, + } = await supertest + .post(`/api/fleet/outputs`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'default output 2', + type: 'elasticsearch', + hosts: ['https://test.fr'], + is_default: true, + }) + .expect(200); - expect(getOutputRes.item).to.have.keys('id', 'name', 'type', 'is_default', 'hosts'); + const { + body: { items: outputs }, + } = await supertest.get(`/api/fleet/outputs`).expect(200); + + const defaultOutputs = outputs.filter((o: any) => o.is_default); + expect(defaultOutputs).to.have.length(1); + expect(defaultOutputs[0].id).eql(output2.id); + }); + + it('should toggle default monitoring output when creating a new default monitoring output ', async function () { + await supertest + .post(`/api/fleet/outputs`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'default monitoring output 1', + type: 'elasticsearch', + hosts: ['https://test.fr'], + is_default_monitoring: true, + }) + .expect(200); + + const { + body: { item: output2 }, + } = await supertest + .post(`/api/fleet/outputs`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'default monitoring output 2', + type: 'elasticsearch', + hosts: ['https://test.fr'], + is_default_monitoring: true, + }) + .expect(200); + + const { + body: { items: outputs }, + } = await supertest.get(`/api/fleet/outputs`).expect(200); + + const defaultOutputs = outputs.filter((o: any) => o.is_default_monitoring); + expect(defaultOutputs).to.have.length(1); + expect(defaultOutputs[0].id).eql(output2.id); + }); }); - it('PUT /output/{defaultOutputId} should explicitly set port on ES hosts', async function () { - await supertest - .put(`/api/fleet/outputs/${defaultOutputId}`) - .set('kbn-xsrf', 'xxxx') - .send({ hosts: ['https://test.fr'] }) - .expect(200); - - const { body: getSettingsRes } = await supertest - .get(`/api/fleet/outputs/${defaultOutputId}`) - .expect(200); - expect(getSettingsRes.item.hosts).to.eql(['https://test.fr:443']); + describe('DELETE /outputs/{outputId}', () => { + let outputId: string; + let defaultOutputIdToDelete: string; + let defaultMonitoringOutputId: string; + + before(async () => { + const { body: postResponse } = await supertest + .post(`/api/fleet/outputs`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Output to delete test', + type: 'elasticsearch', + hosts: ['https://test.fr'], + }) + .expect(200); + outputId = postResponse.item.id; + + const { body: defaultOutputPostResponse } = await supertest + .post(`/api/fleet/outputs`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Default Output to delete test', + type: 'elasticsearch', + hosts: ['https://test.fr'], + is_default: true, + }) + .expect(200); + defaultOutputIdToDelete = defaultOutputPostResponse.item.id; + const { body: defaultMonitoringOutputPostResponse } = await supertest + .post(`/api/fleet/outputs`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Default Output to delete test', + type: 'elasticsearch', + hosts: ['https://test.fr'], + is_default_monitoring: true, + }) + .expect(200); + defaultMonitoringOutputId = defaultMonitoringOutputPostResponse.item.id; + }); + + it('should return a 400 when deleting a default output ', async function () { + await supertest + .delete(`/api/fleet/outputs/${defaultOutputIdToDelete}`) + .set('kbn-xsrf', 'xxxx') + .expect(400); + }); + + it('should return a 400 when deleting a default output ', async function () { + await supertest + .delete(`/api/fleet/outputs/${defaultMonitoringOutputId}`) + .set('kbn-xsrf', 'xxxx') + .expect(400); + }); + + it('should return a 404 when deleting a non existing output ', async function () { + await supertest + .delete(`/api/fleet/outputs/idonotexists`) + .set('kbn-xsrf', 'xxxx') + .expect(404); + }); + + it('should allow to delete an output ', async function () { + const { body: deleteResponse } = await supertest + .delete(`/api/fleet/outputs/${outputId}`) + .set('kbn-xsrf', 'xxxx') + .expect(200); + + expect(deleteResponse.id).to.eql(outputId); + }); }); }); }