diff --git a/oas_docs/bundle.json b/oas_docs/bundle.json index 1dd90a0ce138a..d88e58e5eeee1 100644 --- a/oas_docs/bundle.json +++ b/oas_docs/bundle.json @@ -25832,6 +25832,200 @@ "x-state": "Technical Preview; added in 9.2.0" } }, + "/api/fleet/cloud_connectors/{cloudConnectorId}/usage": { + "get": { + "description": "[Required authorization] Route required privileges: fleet-agent-policies-read OR integrations-read.", + "operationId": "get-fleet-cloud-connectors-cloudconnectorid-usage", + "parameters": [ + { + "description": "The unique identifier of the cloud connector.", + "in": "path", + "name": "cloudConnectorId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "The page number for pagination.", + "in": "query", + "name": "page", + "required": false, + "schema": { + "minimum": 1, + "type": "number" + } + }, + { + "description": "The number of items per page.", + "in": "query", + "name": "perPage", + "required": false, + "schema": { + "minimum": 1, + "type": "number" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "examples": { + "getCloudConnectorUsageResponseExample": { + "description": "Example response showing package policies using the cloud connector", + "value": { + "items": [ + { + "created_at": "2025-01-16T09:00:00.000Z", + "id": "package-policy-1", + "name": "CSPM AWS Policy", + "package": { + "name": "cloud_security_posture", + "title": "Cloud Security Posture Management", + "version": "3.1.1" + }, + "policy_ids": [ + "policy-id-123", + "policy-id-456" + ], + "updated_at": "2025-01-16T09:00:00.000Z" + } + ], + "page": 1, + "perPage": 20, + "total": 2 + } + } + }, + "schema": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "package": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "title": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "name", + "title", + "version" + ], + "type": "object" + }, + "policy_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "policy_ids", + "created_at", + "updated_at" + ], + "type": "object" + }, + "type": "array" + }, + "page": { + "type": "number" + }, + "perPage": { + "type": "number" + }, + "total": { + "type": "number" + } + }, + "required": [ + "items", + "total", + "page", + "perPage" + ], + "type": "object" + } + } + }, + "description": "OK: A successful request." + }, + "400": { + "content": { + "application/json": { + "examples": { + "genericErrorResponseExample": { + "description": "Example of a generic error response", + "value": { + "error": "Bad Request", + "message": "Cloud connector not found", + "statusCode": 400 + } + } + }, + "schema": { + "additionalProperties": false, + "description": "Generic Error", + "properties": { + "attributes": {}, + "error": { + "type": "string" + }, + "errorType": { + "type": "string" + }, + "message": { + "type": "string" + }, + "statusCode": { + "type": "number" + } + }, + "required": [ + "message", + "attributes" + ], + "type": "object" + } + } + }, + "description": "A bad request." + } + }, + "summary": "Get cloud connector usage (package policies using the connector)", + "tags": [ + "Fleet cloud connectors" + ], + "x-state": "Technical Preview; added in 9.2.0" + } + }, "/api/fleet/data_streams": { "get": { "description": "[Required authorization] Route required privileges: fleet-agents-all AND fleet-agent-policies-all AND fleet-settings-all.", diff --git a/oas_docs/bundle.serverless.json b/oas_docs/bundle.serverless.json index fca3d99db9b79..c5d14ed18e987 100644 --- a/oas_docs/bundle.serverless.json +++ b/oas_docs/bundle.serverless.json @@ -25832,6 +25832,200 @@ "x-state": "Technical Preview" } }, + "/api/fleet/cloud_connectors/{cloudConnectorId}/usage": { + "get": { + "description": "[Required authorization] Route required privileges: fleet-agent-policies-read OR integrations-read.", + "operationId": "get-fleet-cloud-connectors-cloudconnectorid-usage", + "parameters": [ + { + "description": "The unique identifier of the cloud connector.", + "in": "path", + "name": "cloudConnectorId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "The page number for pagination.", + "in": "query", + "name": "page", + "required": false, + "schema": { + "minimum": 1, + "type": "number" + } + }, + { + "description": "The number of items per page.", + "in": "query", + "name": "perPage", + "required": false, + "schema": { + "minimum": 1, + "type": "number" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "examples": { + "getCloudConnectorUsageResponseExample": { + "description": "Example response showing package policies using the cloud connector", + "value": { + "items": [ + { + "created_at": "2025-01-16T09:00:00.000Z", + "id": "package-policy-1", + "name": "CSPM AWS Policy", + "package": { + "name": "cloud_security_posture", + "title": "Cloud Security Posture Management", + "version": "3.1.1" + }, + "policy_ids": [ + "policy-id-123", + "policy-id-456" + ], + "updated_at": "2025-01-16T09:00:00.000Z" + } + ], + "page": 1, + "perPage": 20, + "total": 2 + } + } + }, + "schema": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "package": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "title": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "name", + "title", + "version" + ], + "type": "object" + }, + "policy_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "policy_ids", + "created_at", + "updated_at" + ], + "type": "object" + }, + "type": "array" + }, + "page": { + "type": "number" + }, + "perPage": { + "type": "number" + }, + "total": { + "type": "number" + } + }, + "required": [ + "items", + "total", + "page", + "perPage" + ], + "type": "object" + } + } + }, + "description": "OK: A successful request." + }, + "400": { + "content": { + "application/json": { + "examples": { + "genericErrorResponseExample": { + "description": "Example of a generic error response", + "value": { + "error": "Bad Request", + "message": "Cloud connector not found", + "statusCode": 400 + } + } + }, + "schema": { + "additionalProperties": false, + "description": "Generic Error", + "properties": { + "attributes": {}, + "error": { + "type": "string" + }, + "errorType": { + "type": "string" + }, + "message": { + "type": "string" + }, + "statusCode": { + "type": "number" + } + }, + "required": [ + "message", + "attributes" + ], + "type": "object" + } + } + }, + "description": "A bad request." + } + }, + "summary": "Get cloud connector usage (package policies using the connector)", + "tags": [ + "Fleet cloud connectors" + ], + "x-state": "Technical Preview" + } + }, "/api/fleet/data_streams": { "get": { "description": "[Required authorization] Route required privileges: fleet-agents-all AND fleet-agent-policies-all AND fleet-settings-all.", diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index 841d9317bb151..e0dd854142e02 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -31850,6 +31850,150 @@ paths: x-metaTags: - content: Kibana, Elastic Cloud Serverless name: product_name + /api/fleet/cloud_connectors/{cloudConnectorId}/usage: + get: + description: |- + **Spaces method and path for this operation:** + +
get /s/{space_id}/api/fleet/cloud_connectors/{cloudConnectorId}/usage
+ + Refer to [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces) for more information. + + [Required authorization] Route required privileges: fleet-agent-policies-read OR integrations-read. + operationId: get-fleet-cloud-connectors-cloudconnectorid-usage + parameters: + - description: The unique identifier of the cloud connector. + in: path + name: cloudConnectorId + required: true + schema: + type: string + - description: The page number for pagination. + in: query + name: page + required: false + schema: + minimum: 1 + type: number + - description: The number of items per page. + in: query + name: perPage + required: false + schema: + minimum: 1 + type: number + responses: + '200': + content: + application/json: + examples: + getCloudConnectorUsageResponseExample: + description: Example response showing package policies using the cloud connector + value: + items: + - created_at: '2025-01-16T09:00:00.000Z' + id: package-policy-1 + name: CSPM AWS Policy + package: + name: cloud_security_posture + title: Cloud Security Posture Management + version: 3.1.1 + policy_ids: + - policy-id-123 + - policy-id-456 + updated_at: '2025-01-16T09:00:00.000Z' + page: 1 + perPage: 20 + total: 2 + schema: + additionalProperties: false + type: object + properties: + items: + items: + additionalProperties: false + type: object + properties: + created_at: + type: string + id: + type: string + name: + type: string + package: + additionalProperties: false + type: object + properties: + name: + type: string + title: + type: string + version: + type: string + required: + - name + - title + - version + policy_ids: + items: + type: string + type: array + updated_at: + type: string + required: + - id + - name + - policy_ids + - created_at + - updated_at + type: array + page: + type: number + perPage: + type: number + total: + type: number + required: + - items + - total + - page + - perPage + description: 'OK: A successful request.' + '400': + content: + application/json: + examples: + genericErrorResponseExample: + description: Example of a generic error response + value: + error: Bad Request + message: Cloud connector not found + statusCode: 400 + schema: + additionalProperties: false + description: Generic Error + type: object + properties: + attributes: {} + error: + type: string + errorType: + type: string + message: + type: string + statusCode: + type: number + required: + - message + - attributes + description: A bad request. + summary: Get cloud connector usage (package policies using the connector) + tags: + - Fleet cloud connectors + x-state: Technical Preview + x-metaTags: + - content: Kibana, Elastic Cloud Serverless + name: product_name /api/fleet/data_streams: get: description: |- diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index 3f4cbb93315eb..8e01465657dd2 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -34409,6 +34409,150 @@ paths: x-metaTags: - content: Kibana name: product_name + /api/fleet/cloud_connectors/{cloudConnectorId}/usage: + get: + description: |- + **Spaces method and path for this operation:** + +
get /s/{space_id}/api/fleet/cloud_connectors/{cloudConnectorId}/usage
+ + Refer to [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces) for more information. + + [Required authorization] Route required privileges: fleet-agent-policies-read OR integrations-read. + operationId: get-fleet-cloud-connectors-cloudconnectorid-usage + parameters: + - description: The unique identifier of the cloud connector. + in: path + name: cloudConnectorId + required: true + schema: + type: string + - description: The page number for pagination. + in: query + name: page + required: false + schema: + minimum: 1 + type: number + - description: The number of items per page. + in: query + name: perPage + required: false + schema: + minimum: 1 + type: number + responses: + '200': + content: + application/json: + examples: + getCloudConnectorUsageResponseExample: + description: Example response showing package policies using the cloud connector + value: + items: + - created_at: '2025-01-16T09:00:00.000Z' + id: package-policy-1 + name: CSPM AWS Policy + package: + name: cloud_security_posture + title: Cloud Security Posture Management + version: 3.1.1 + policy_ids: + - policy-id-123 + - policy-id-456 + updated_at: '2025-01-16T09:00:00.000Z' + page: 1 + perPage: 20 + total: 2 + schema: + additionalProperties: false + type: object + properties: + items: + items: + additionalProperties: false + type: object + properties: + created_at: + type: string + id: + type: string + name: + type: string + package: + additionalProperties: false + type: object + properties: + name: + type: string + title: + type: string + version: + type: string + required: + - name + - title + - version + policy_ids: + items: + type: string + type: array + updated_at: + type: string + required: + - id + - name + - policy_ids + - created_at + - updated_at + type: array + page: + type: number + perPage: + type: number + total: + type: number + required: + - items + - total + - page + - perPage + description: 'OK: A successful request.' + '400': + content: + application/json: + examples: + genericErrorResponseExample: + description: Example of a generic error response + value: + error: Bad Request + message: Cloud connector not found + statusCode: 400 + schema: + additionalProperties: false + description: Generic Error + type: object + properties: + attributes: {} + error: + type: string + errorType: + type: string + message: + type: string + statusCode: + type: number + required: + - message + - attributes + description: A bad request. + summary: Get cloud connector usage (package policies using the connector) + tags: + - Fleet cloud connectors + x-state: Technical Preview; added in 9.2.0 + x-metaTags: + - content: Kibana + name: product_name /api/fleet/data_streams: get: description: |- diff --git a/x-pack/platform/plugins/shared/fleet/common/constants/routes.ts b/x-pack/platform/plugins/shared/fleet/common/constants/routes.ts index 4a60b66613aa5..fef4412432e71 100644 --- a/x-pack/platform/plugins/shared/fleet/common/constants/routes.ts +++ b/x-pack/platform/plugins/shared/fleet/common/constants/routes.ts @@ -106,6 +106,7 @@ export const CLOUD_CONNECTOR_API_ROUTES = { CREATE_PATTERN: `${CLOUD_CONNECTOR_API_ROOT}`, UPDATE_PATTERN: `${CLOUD_CONNECTOR_API_ROOT}/{cloudConnectorId}`, DELETE_PATTERN: `${CLOUD_CONNECTOR_API_ROOT}/{cloudConnectorId}`, + USAGE_PATTERN: `${CLOUD_CONNECTOR_API_ROOT}/{cloudConnectorId}/usage`, }; // Kubernetes Manifest API routes diff --git a/x-pack/platform/plugins/shared/fleet/common/types/rest_spec/cloud_connector.ts b/x-pack/platform/plugins/shared/fleet/common/types/rest_spec/cloud_connector.ts index 08973f826e153..dff0047b3d98e 100644 --- a/x-pack/platform/plugins/shared/fleet/common/types/rest_spec/cloud_connector.ts +++ b/x-pack/platform/plugins/shared/fleet/common/types/rest_spec/cloud_connector.ts @@ -41,3 +41,23 @@ export interface UpdateCloudConnectorResponse { export interface DeleteCloudConnectorResponse { id: string; } + +export interface CloudConnectorUsageItem { + id: string; + name: string; + package?: { + name: string; + title: string; + version: string; + }; + policy_ids: string[]; + created_at: string; + updated_at: string; +} + +export interface GetCloudConnectorUsageResponse { + items: CloudConnectorUsageItem[]; + total: number; + page: number; + perPage: number; +} diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/cloud_connector/handlers.ts b/x-pack/platform/plugins/shared/fleet/server/routes/cloud_connector/handlers.ts index 1a1063cbaa365..5f50dc17bbe60 100644 --- a/x-pack/platform/plugins/shared/fleet/server/routes/cloud_connector/handlers.ts +++ b/x-pack/platform/plugins/shared/fleet/server/routes/cloud_connector/handlers.ts @@ -7,7 +7,8 @@ import type { TypeOf } from '@kbn/config-schema'; -import { cloudConnectorService } from '../../services'; +import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../common/constants'; +import { cloudConnectorService, packagePolicyService } from '../../services'; import type { FleetRequestHandler } from '../../types'; import { appContextService } from '../../services/app_context'; import type { @@ -18,6 +19,8 @@ import type { DeleteCloudConnectorResponse, UpdateCloudConnectorRequest, CreateCloudConnectorRequest, + GetCloudConnectorUsageResponse, + CloudConnectorUsageItem, } from '../../../common/types/rest_spec/cloud_connector'; import type { CreateCloudConnectorRequestSchema, @@ -25,6 +28,7 @@ import type { GetCloudConnectorsRequestSchema, UpdateCloudConnectorRequestSchema, DeleteCloudConnectorRequestSchema, + GetCloudConnectorUsageRequestSchema, } from '../../types/rest_spec/cloud_connector'; export const createCloudConnectorHandler: FleetRequestHandler< @@ -193,3 +197,78 @@ export const deleteCloudConnectorHandler: FleetRequestHandler< }); } }; + +export const getCloudConnectorUsageHandler: FleetRequestHandler< + TypeOf, + TypeOf +> = async (context, request, response) => { + const fleetContext = await context.fleet; + const { internalSoClient } = fleetContext; + const cloudConnectorId = request.params.cloudConnectorId; + const page = request.query?.page || 1; + const perPage = request.query?.perPage || 10; + const logger = appContextService + .getLogger() + .get('CloudConnectorService getCloudConnectorUsageHandler'); + + try { + logger.info( + `Getting usage for cloud connector ${cloudConnectorId} (page: ${page}, perPage: ${perPage})` + ); + + // First, verify the cloud connector exists + await cloudConnectorService.getById(internalSoClient, cloudConnectorId); + + // Query package policies that use this cloud connector with pagination + logger.debug( + `Querying package policies with kuery: ${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.attributes.cloud_connector_id:"${cloudConnectorId}"` + ); + + const result = await packagePolicyService.list(internalSoClient, { + page, + perPage, + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.attributes.cloud_connector_id:"${cloudConnectorId}"`, + }); + + logger.debug(`Found ${result?.total || 0} total package policies using cloud connector`); + + const usageItems: CloudConnectorUsageItem[] = (result?.items || []).map((policy) => ({ + id: policy.id, + name: policy.name, + package: policy.package + ? { + name: policy.package.name, + title: policy.package.title, + version: policy.package.version, + } + : undefined, + policy_ids: policy.policy_ids, + created_at: policy.created_at, + updated_at: policy.updated_at, + })); + + logger.info( + `Successfully retrieved usage for cloud connector ${cloudConnectorId}: ${ + usageItems.length + } of ${result?.total || 0} policies` + ); + const body: GetCloudConnectorUsageResponse = { + items: usageItems, + total: result?.total || 0, + page, + perPage, + }; + return response.ok({ body }); + } catch (error) { + logger.error( + `Failed to get usage for cloud connector ${cloudConnectorId}: ${error.message}`, + error + ); + return response.customError({ + statusCode: 400, + body: { + message: error.message || 'Failed to get cloud connector usage', + }, + }); + } +}; diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/cloud_connector/index.ts b/x-pack/platform/plugins/shared/fleet/server/routes/cloud_connector/index.ts index 3938bc4a58c6d..cb95a7ad53925 100644 --- a/x-pack/platform/plugins/shared/fleet/server/routes/cloud_connector/index.ts +++ b/x-pack/platform/plugins/shared/fleet/server/routes/cloud_connector/index.ts @@ -22,6 +22,8 @@ import { UpdateCloudConnectorResponseSchema, DeleteCloudConnectorRequestSchema, DeleteCloudConnectorResponseSchema, + GetCloudConnectorUsageRequestSchema, + GetCloudConnectorUsageResponseSchema, } from '../../types/rest_spec/cloud_connector'; import { @@ -30,6 +32,7 @@ import { getCloudConnectorHandler, updateCloudConnectorHandler, deleteCloudConnectorHandler, + getCloudConnectorUsageHandler, } from './handlers'; export const registerRoutes = (router: FleetAuthzRouter) => { @@ -257,4 +260,102 @@ export const registerRoutes = (router: FleetAuthzRouter) => { }, deleteCloudConnectorHandler ); + + // GET /api/fleet/cloud_connectors/{cloudConnectorId}/usage + router.versioned + .get({ + path: CLOUD_CONNECTOR_API_ROUTES.USAGE_PATTERN, + security: { + authz: { + requiredPrivileges: [ + { + anyRequired: [ + FLEET_API_PRIVILEGES.AGENT_POLICIES.READ, + FLEET_API_PRIVILEGES.INTEGRATIONS.READ, + ], + }, + ], + }, + }, + summary: 'Get cloud connector usage (package policies using the connector)', + options: { + tags: ['oas-tag:Fleet cloud connectors'], + availability: { + since: '9.2.0', + stability: 'experimental', + }, + }, + }) + .addVersion( + { + version: API_VERSIONS.public.v1, + options: { + oasOperationObject: () => ({ + responses: { + '200': { + content: { + 'application/json': { + examples: { + getCloudConnectorUsageResponseExample: { + description: + 'Example response showing package policies using the cloud connector', + value: { + items: [ + { + id: 'package-policy-1', + name: 'CSPM AWS Policy', + package: { + name: 'cloud_security_posture', + title: 'Cloud Security Posture Management', + version: '3.1.1', + }, + policy_ids: ['policy-id-123', 'policy-id-456'], + created_at: '2025-01-16T09:00:00.000Z', + updated_at: '2025-01-16T09:00:00.000Z', + }, + ], + total: 2, + page: 1, + perPage: 20, + }, + }, + }, + }, + }, + }, + '400': { + content: { + 'application/json': { + examples: { + genericErrorResponseExample: { + description: 'Example of a generic error response', + value: { + statusCode: 400, + error: 'Bad Request', + message: 'Cloud connector not found', + }, + }, + }, + }, + }, + }, + }, + }), + }, + validate: { + request: GetCloudConnectorUsageRequestSchema, + response: { + 200: { + body: () => GetCloudConnectorUsageResponseSchema, + description: 'OK: A successful request.', + }, + 400: { + body: genericErrorResponse, + description: 'A bad request.', + }, + }, + }, + }, + getCloudConnectorUsageHandler + ); }; diff --git a/x-pack/platform/plugins/shared/fleet/server/types/rest_spec/cloud_connector.ts b/x-pack/platform/plugins/shared/fleet/server/types/rest_spec/cloud_connector.ts index a5c5c7a628035..6755a92dbd5c2 100644 --- a/x-pack/platform/plugins/shared/fleet/server/types/rest_spec/cloud_connector.ts +++ b/x-pack/platform/plugins/shared/fleet/server/types/rest_spec/cloud_connector.ts @@ -184,3 +184,47 @@ export const UpdateCloudConnectorResponseSchema = schema.object({ updated_at: schema.string(), }), }); + +export const GetCloudConnectorUsageRequestSchema = { + params: schema.object({ + cloudConnectorId: schema.string({ + meta: { description: 'The unique identifier of the cloud connector.' }, + }), + }), + query: schema.object({ + page: schema.maybe( + schema.number({ + min: 1, + meta: { description: 'The page number for pagination.' }, + }) + ), + perPage: schema.maybe( + schema.number({ + min: 1, + meta: { description: 'The number of items per page.' }, + }) + ), + }), +}; + +export const GetCloudConnectorUsageResponseSchema = schema.object({ + items: schema.arrayOf( + schema.object({ + id: schema.string(), + name: schema.string(), + package: schema.maybe( + schema.object({ + name: schema.string(), + title: schema.string(), + version: schema.string(), + }) + ), + policy_ids: schema.arrayOf(schema.string()), + created_at: schema.string(), + updated_at: schema.string(), + }) + ), + total: schema.number(), + page: schema.number(), + perPage: schema.number(), +}); diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/test_subjects.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/test_subjects.ts index 44f2216ab8141..222c7425e22de 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/test_subjects.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/test_subjects.ts @@ -80,3 +80,21 @@ export const NAMESPACE_INPUT_TEST_SUBJ = 'namespaceInputTestId'; // Cloud Connector test subjects export const AWS_CLOUD_CONNECTOR_SUPER_SELECT_TEST_SUBJ = 'aws-cloud-connector-super-select'; export const AZURE_CLOUD_CONNECTOR_SUPER_SELECT_TEST_SUBJ = 'azure-cloud-connector-super-select'; +export const CLOUD_CONNECTOR_EDIT_ICON_TEST_SUBJ = 'cloudConnectorEditIcon'; +export const getCloudConnectorEditIconTestSubj = (connectorId: string) => + `${CLOUD_CONNECTOR_EDIT_ICON_TEST_SUBJ}-${connectorId}`; + +export const CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS = { + FLYOUT: 'cloudConnectorPoliciesFlyout', + CLOSE_BUTTON: 'euiFlyoutCloseButton', + TITLE: 'cloudConnectorPoliciesFlyoutTitle', + IDENTIFIER_TEXT: 'cloudConnectorIdentifierText', + COPY_IDENTIFIER_BUTTON: 'cloudConnectorCopyIdentifier', + NAME_INPUT: 'cloudConnectorNameInput', + SAVE_NAME_BUTTON: 'cloudConnectorSaveNameButton', + USAGE_COUNT_TEXT: 'cloudConnectorUsageCountText', + POLICIES_TABLE: 'cloudConnectorPoliciesTable', + POLICY_LINK: 'cloudConnectorPolicyLink', + EMPTY_STATE: 'cloudConnectorPoliciesEmptyState', + ERROR_STATE: 'cloudConnectorPoliciesErrorState', +}; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/cloud_connector_policies_flyout/index.test.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/cloud_connector_policies_flyout/index.test.tsx new file mode 100644 index 0000000000000..696cc463744d4 --- /dev/null +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/cloud_connector_policies_flyout/index.test.tsx @@ -0,0 +1,478 @@ +/* + * 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 { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nProvider } from '@kbn/i18n-react'; +import { CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS } from '@kbn/cloud-security-posture-common'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import type { UseQueryResult } from '@kbn/react-query'; +import { QueryClient, QueryClientProvider } from '@kbn/react-query'; +import { CloudConnectorPoliciesFlyout } from '.'; +import type { CloudConnectorUsageItem } from '../hooks/use_cloud_connector_usage'; +import { useCloudConnectorUsage } from '../hooks/use_cloud_connector_usage'; +import { useUpdateCloudConnector } from '../hooks/use_update_cloud_connector'; + +jest.mock('@kbn/kibana-react-plugin/public'); +jest.mock('../hooks/use_cloud_connector_usage'); +jest.mock('../hooks/use_update_cloud_connector'); + +const mockUseKibana = useKibana as jest.MockedFunction; +const mockUseCloudConnectorUsage = useCloudConnectorUsage as jest.MockedFunction< + typeof useCloudConnectorUsage +>; +const mockUseUpdateCloudConnector = useUpdateCloudConnector as jest.MockedFunction< + typeof useUpdateCloudConnector +>; + +describe('CloudConnectorPoliciesFlyout', () => { + let queryClient: QueryClient; + const mockOnClose = jest.fn(); + const mockNavigateToApp = jest.fn(); + + const defaultProps = { + cloudConnectorId: 'connector-123', + cloudConnectorName: 'Test Connector', + cloudConnectorVars: { + role_arn: { value: 'arn:aws:iam::123456789012:role/TestRole' }, + external_id: { value: { isSecretRef: true, id: 'secret-ref-id-123' } }, + }, + provider: 'aws' as const, + onClose: mockOnClose, + }; + + const mockUsageData: CloudConnectorUsageItem[] = [ + { + id: 'policy-1', + name: 'Test Policy 1', + package: { + name: 'cloud_security_posture', + title: 'Cloud Security Posture', + version: '1.0.0', + }, + policy_ids: ['agent-policy-1'], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-02T00:00:00Z', + }, + ]; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + mockUseKibana.mockReturnValue({ + services: { + application: { + navigateToApp: mockNavigateToApp, + }, + }, + } as unknown as ReturnType); + + mockUseCloudConnectorUsage.mockReturnValue({ + data: { items: mockUsageData, total: mockUsageData.length, page: 1, perPage: 10 }, + isLoading: false, + error: null, + } as unknown as UseQueryResult<{ items: CloudConnectorUsageItem[]; total: number; page: number; perPage: number }>); + + const mockMutate = jest.fn(); + mockUseUpdateCloudConnector.mockReturnValue({ + mutate: mockMutate, + isLoading: false, + } as unknown as ReturnType); + + mockOnClose.mockClear(); + mockNavigateToApp.mockClear(); + }); + + afterEach(() => { + queryClient.clear(); + }); + + const renderFlyout = (props = {}) => { + return render( + + + + + + ); + }; + + it('should render flyout with connector name and ARN', () => { + renderFlyout(); + + expect( + screen.getByTestId(CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.TITLE) + ).toHaveTextContent('Test Connector'); + expect( + screen.getByTestId(CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.IDENTIFIER_TEXT) + ).toHaveTextContent('Role ARN: arn:aws:iam::123456789012:role/TestRole'); + }); + + it('should render usage table with policies', async () => { + renderFlyout(); + + await waitFor(() => { + expect( + screen.getByTestId(CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.POLICIES_TABLE) + ).toBeInTheDocument(); + }); + + expect(screen.getByText('Test Policy 1')).toBeInTheDocument(); + expect(screen.getByText('Cloud Security Posture')).toBeInTheDocument(); + }); + + it('should show empty state when no policies use the connector', () => { + mockUseCloudConnectorUsage.mockReturnValue({ + data: { items: [], total: 0, page: 1, perPage: 10 }, + isLoading: false, + error: null, + } as unknown as UseQueryResult<{ items: CloudConnectorUsageItem[]; total: number; page: number; perPage: number }>); + + renderFlyout(); + + expect( + screen.getByTestId(CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.EMPTY_STATE) + ).toBeInTheDocument(); + expect(screen.getByText('No integrations using this cloud connector')).toBeInTheDocument(); + }); + + it('should show loading state', () => { + mockUseCloudConnectorUsage.mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + } as unknown as UseQueryResult<{ items: CloudConnectorUsageItem[]; total: number; page: number; perPage: number }>); + + renderFlyout(); + + expect( + screen.getByTestId(CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.POLICIES_TABLE) + ).toHaveClass('euiBasicTable-loading'); + }); + + it('should show error state', () => { + mockUseCloudConnectorUsage.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('Failed to fetch'), + } as unknown as UseQueryResult<{ items: CloudConnectorUsageItem[]; total: number; page: number; perPage: number }>); + + renderFlyout(); + + expect( + screen.getByTestId(CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.ERROR_STATE) + ).toBeInTheDocument(); + expect(screen.getByText('Failed to load policies')).toBeInTheDocument(); + }); + + it('should enable save button when name is changed', async () => { + const user = userEvent.setup(); + renderFlyout(); + + const nameInput = screen.getByTestId(CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.NAME_INPUT); + const saveButton = screen.getByTestId( + CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.SAVE_NAME_BUTTON + ); + + expect(saveButton).toBeDisabled(); + + await user.clear(nameInput); + await user.type(nameInput, 'New Name'); + + expect(saveButton).toBeEnabled(); + }); + + it('should call mutate when save button is clicked', async () => { + const user = userEvent.setup(); + const mockMutate = jest.fn(); + mockUseUpdateCloudConnector.mockReturnValue({ + mutate: mockMutate, + isLoading: false, + } as unknown as ReturnType); + + renderFlyout(); + + const nameInput = screen.getByTestId(CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.NAME_INPUT); + const saveButton = screen.getByTestId( + CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.SAVE_NAME_BUTTON + ); + + await user.clear(nameInput); + await user.type(nameInput, 'New Name'); + await user.click(saveButton); + + expect(mockMutate).toHaveBeenCalledWith({ name: 'New Name' }); + }); + + it('should navigate to policy when clicking policy name', async () => { + const user = userEvent.setup(); + renderFlyout(); + + await waitFor(() => { + expect(screen.getByText('Test Policy 1')).toBeInTheDocument(); + }); + + const policyLink = screen.getByTestId( + CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.POLICY_LINK + ); + await user.click(policyLink); + + expect(mockNavigateToApp).toHaveBeenCalledWith('integrations', { + path: '/edit-integration/policy-1', + }); + }); + + it('should close flyout when onClose is called', async () => { + const user = userEvent.setup(); + renderFlyout(); + + const closeButton = screen.getByTestId( + CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.CLOSE_BUTTON + ); + await user.click(closeButton); + + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('should display Azure subscription ID for Azure connector', () => { + renderFlyout({ + provider: 'azure', + cloudConnectorVars: { + tenant_id: { value: 'tenant-123' }, + azure_credentials_cloud_connector_id: { value: 'subscription-123' }, + }, + }); + + expect( + screen.getByTestId(CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.IDENTIFIER_TEXT) + ).toHaveTextContent('Cloud Connector ID: subscription-123'); + }); + + describe('pagination', () => { + it('should call useCloudConnectorUsage with initial pagination parameters', () => { + renderFlyout(); + + expect(mockUseCloudConnectorUsage).toHaveBeenCalledWith('connector-123', 1, 10); + }); + + it('should display pagination controls when there are multiple pages', async () => { + const manyPolicies: CloudConnectorUsageItem[] = Array.from({ length: 15 }, (_, i) => ({ + id: `policy-${i + 1}`, + name: `Test Policy ${i + 1}`, + package: { + name: 'cloud_security_posture', + title: 'Cloud Security Posture', + version: '1.0.0', + }, + policy_ids: [`agent-policy-${i + 1}`], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-02T00:00:00Z', + })); + + mockUseCloudConnectorUsage.mockReturnValue({ + data: { items: manyPolicies.slice(0, 10), total: 15, page: 1, perPage: 10 }, + isLoading: false, + error: null, + } as unknown as UseQueryResult<{ + items: CloudConnectorUsageItem[]; + total: number; + page: number; + perPage: number; + }>); + + renderFlyout(); + + await waitFor(() => { + expect( + screen.getByTestId(CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.POLICIES_TABLE) + ).toBeInTheDocument(); + }); + + // EuiBasicTable renders pagination when totalItemCount > pageSize + expect(screen.getByText('Rows per page: 10')).toBeInTheDocument(); + }); + + it('should update pagination when page is changed', async () => { + const user = userEvent.setup(); + + mockUseCloudConnectorUsage.mockReturnValue({ + data: { items: mockUsageData, total: 25, page: 1, perPage: 10 }, + isLoading: false, + error: null, + } as unknown as UseQueryResult<{ + items: CloudConnectorUsageItem[]; + total: number; + page: number; + perPage: number; + }>); + + renderFlyout(); + + await waitFor(() => { + expect( + screen.getByTestId(CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.POLICIES_TABLE) + ).toBeInTheDocument(); + }); + + // Click next page button + const nextPageButton = screen.getByLabelText('Next page'); + await user.click(nextPageButton); + + // Verify the hook was called with page 2 + expect(mockUseCloudConnectorUsage).toHaveBeenLastCalledWith('connector-123', 2, 10); + }); + + it('should update pagination when page size is changed', async () => { + const user = userEvent.setup(); + + mockUseCloudConnectorUsage.mockReturnValue({ + data: { items: mockUsageData, total: 30, page: 1, perPage: 10 }, + isLoading: false, + error: null, + } as unknown as UseQueryResult<{ + items: CloudConnectorUsageItem[]; + total: number; + page: number; + perPage: number; + }>); + + renderFlyout(); + + await waitFor(() => { + expect( + screen.getByTestId(CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.POLICIES_TABLE) + ).toBeInTheDocument(); + }); + + // Click on "Rows per page" button and select 25 + const rowsPerPageButton = screen.getByText('Rows per page: 10'); + await user.click(rowsPerPageButton); + + const option25 = await screen.findByText('25 rows'); + await user.click(option25); + + // Verify the hook was called with new page size + expect(mockUseCloudConnectorUsage).toHaveBeenLastCalledWith('connector-123', 1, 25); + }); + }); + + describe('name validation', () => { + it('should show validation error when name exceeds 255 characters', async () => { + const user = userEvent.setup(); + renderFlyout(); + + const nameInput = screen.getByTestId( + CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.NAME_INPUT + ); + + await user.clear(nameInput); + await user.click(nameInput); + await user.paste('a'.repeat(256)); + + expect( + screen.getByText('Cloud Connector Name must be 255 characters or less') + ).toBeInTheDocument(); + }); + + it('should keep save button disabled when name exceeds 255 characters', async () => { + const user = userEvent.setup(); + renderFlyout(); + + const nameInput = screen.getByTestId( + CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.NAME_INPUT + ); + const saveButton = screen.getByTestId( + CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.SAVE_NAME_BUTTON + ); + + await user.clear(nameInput); + await user.click(nameInput); + await user.paste('a'.repeat(256)); + + expect(saveButton).toBeDisabled(); + }); + + it('should show validation error when name is empty', async () => { + const user = userEvent.setup(); + renderFlyout(); + + const nameInput = screen.getByTestId( + CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.NAME_INPUT + ); + + await user.clear(nameInput); + + expect(screen.getByText('Cloud Connector Name is required')).toBeInTheDocument(); + }); + + it('should keep save button disabled when name is empty', async () => { + const user = userEvent.setup(); + renderFlyout(); + + const nameInput = screen.getByTestId( + CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.NAME_INPUT + ); + const saveButton = screen.getByTestId( + CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.SAVE_NAME_BUTTON + ); + + await user.clear(nameInput); + + expect(saveButton).toBeDisabled(); + }); + + it('should enable save button for valid name that is different from original', async () => { + const user = userEvent.setup(); + renderFlyout(); + + const nameInput = screen.getByTestId( + CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.NAME_INPUT + ); + const saveButton = screen.getByTestId( + CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.SAVE_NAME_BUTTON + ); + + await user.clear(nameInput); + await user.click(nameInput); + await user.paste('Valid New Name'); + + expect(saveButton).toBeEnabled(); + expect(screen.queryByText('Cloud Connector Name is required')).not.toBeInTheDocument(); + expect( + screen.queryByText('Cloud Connector Name must be 255 characters or less') + ).not.toBeInTheDocument(); + }); + + it('should accept name with exactly 255 characters', async () => { + const user = userEvent.setup(); + renderFlyout(); + + const nameInput = screen.getByTestId( + CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.NAME_INPUT + ); + const saveButton = screen.getByTestId( + CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.SAVE_NAME_BUTTON + ); + + await user.clear(nameInput); + await user.click(nameInput); + await user.paste('a'.repeat(255)); + + expect(saveButton).toBeEnabled(); + expect( + screen.queryByText('Cloud Connector Name must be 255 characters or less') + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/cloud_connector_policies_flyout/index.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/cloud_connector_policies_flyout/index.tsx new file mode 100644 index 0000000000000..fc22a229dfefd --- /dev/null +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/cloud_connector_policies_flyout/index.tsx @@ -0,0 +1,386 @@ +/* + * 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, { useState, useMemo, useCallback } from 'react'; +import { + EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiTitle, + EuiText, + EuiSpacer, + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiBasicTable, + EuiLink, + EuiEmptyPrompt, + EuiCopy, + EuiButtonIcon, + useGeneratedHtmlId, + type EuiBasicTableColumn, + EuiHorizontalRule, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { pagePathGetters } from '@kbn/fleet-plugin/public'; +import type { CloudConnectorVars } from '@kbn/fleet-plugin/common/types'; +import { CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS } from '@kbn/cloud-security-posture-common'; +import type { CloudProviders } from '../types'; +import { useCloudConnectorUsage } from '../hooks/use_cloud_connector_usage'; +import { useUpdateCloudConnector } from '../hooks/use_update_cloud_connector'; +import { + isAwsCloudConnectorVars, + isAzureCloudConnectorVars, + isCloudConnectorNameValid, +} from '../utils'; +import { CloudConnectorNameField } from '../form/cloud_connector_name_field'; + +interface CloudConnectorPoliciesFlyoutProps { + cloudConnectorId: string; + cloudConnectorName: string; + cloudConnectorVars: CloudConnectorVars; + provider: CloudProviders; + onClose: () => void; +} + +export const CloudConnectorPoliciesFlyout: React.FC = ({ + cloudConnectorId, + cloudConnectorName: initialName, + cloudConnectorVars, + provider, + onClose, +}) => { + const { application } = useKibana().services; + const flyoutTitleId = useGeneratedHtmlId(); + const [cloudConnectorName, setCloudConnectorName] = useState(initialName); + const [editedName, setEditedName] = useState(initialName); + const [isNameValid, setIsNameValid] = useState(() => isCloudConnectorNameValid(initialName)); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + const { + data: usageData, + isLoading, + error, + } = useCloudConnectorUsage( + cloudConnectorId, + pageIndex + 1, // Convert from 0-based to 1-based + pageSize + ); + + const usageItems = usageData?.items || []; + const totalItemCount = usageData?.total || 0; + + const { mutate: updateConnector, isLoading: isUpdating } = useUpdateCloudConnector( + cloudConnectorId, + (updatedConnector) => { + setCloudConnectorName(updatedConnector.name); + setEditedName(updatedConnector.name); + setIsNameValid(true); + } + ); + + // Extract ARN or Subscription ID based on provider + const identifier = useMemo(() => { + if (isAwsCloudConnectorVars(cloudConnectorVars, provider)) { + return cloudConnectorVars.role_arn?.value || ''; + } else if (isAzureCloudConnectorVars(cloudConnectorVars, provider)) { + return cloudConnectorVars.azure_credentials_cloud_connector_id?.value || ''; + } + return ''; + }, [cloudConnectorVars, provider]); + + const identifierLabel = + provider === 'aws' + ? i18n.translate( + 'securitySolutionPackages.cloudSecurityPosture.cloudConnectorPoliciesFlyout.roleArnLabel', + { + defaultMessage: 'Role ARN', + } + ) + : i18n.translate( + 'securitySolutionPackages.cloudSecurityPosture.cloudConnectorPoliciesFlyout.cloudConnectorIdLabel', + { + defaultMessage: 'Cloud Connector ID', + } + ); + + const handleSaveName = () => { + if (editedName && editedName !== cloudConnectorName) { + updateConnector({ name: editedName }); + } + }; + + const handleNameChange = useCallback((name: string, valid: boolean) => { + setEditedName(name); + setIsNameValid(valid); + }, []); + + const isSaveDisabled = !isNameValid || editedName === cloudConnectorName || isUpdating; + + const tableCaption = useMemo( + () => + i18n.translate( + 'securitySolutionPackages.cloudSecurityPosture.cloudConnectorPoliciesFlyout.tableCaption', + { + defaultMessage: 'Integrations using cloud connector {name}', + values: { name: cloudConnectorName }, + } + ), + [cloudConnectorName] + ); + + const pagination = useMemo( + () => ({ + pageIndex, + pageSize, + totalItemCount, + pageSizeOptions: [10, 25, 50], + }), + [pageIndex, pageSize, totalItemCount] + ); + + const onTableChange = useCallback(({ page }: { page?: { index: number; size: number } }) => { + if (page) { + setPageIndex(page.index); + setPageSize(page.size); + } + }, []); + + const handleNavigateToPolicy = useCallback( + (packagePolicyId: string) => { + // Use integrations app route to ensure cancel navigates back to integrations page + const [, path] = pagePathGetters.integration_policy_edit({ packagePolicyId }); + application?.navigateToApp('integrations', { path }); + }, + [application] + ); + + const columns: Array> = useMemo( + () => [ + { + field: 'name', + name: i18n.translate( + 'securitySolutionPackages.cloudSecurityPosture.cloudConnectorPoliciesFlyout.nameColumn', + { + defaultMessage: 'Name', + } + ), + render: (name: string, item) => { + return ( + { + e.preventDefault(); + handleNavigateToPolicy(item.id); + }} + data-test-subj={CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.POLICY_LINK} + > + {name} + + ); + }, + }, + { + field: 'package', + name: i18n.translate( + 'securitySolutionPackages.cloudSecurityPosture.cloudConnectorPoliciesFlyout.integrationTypeColumn', + { + defaultMessage: 'Integration Type', + } + ), + render: (pkg: (typeof usageItems)[0]['package']) => pkg?.title || pkg?.name || '-', + }, + { + field: 'created_at', + name: i18n.translate( + 'securitySolutionPackages.cloudSecurityPosture.cloudConnectorPoliciesFlyout.createdColumn', + { + defaultMessage: 'Created', + } + ), + render: (createdAt: string) => new Date(createdAt).toLocaleDateString(), + }, + { + field: 'updated_at', + name: i18n.translate( + 'securitySolutionPackages.cloudSecurityPosture.cloudConnectorPoliciesFlyout.lastUpdatedColumn', + { + defaultMessage: 'Last Updated', + } + ), + render: (updatedAt: string) => new Date(updatedAt).toLocaleDateString(), + }, + ], + [handleNavigateToPolicy] + ); + + return ( + + + +

+ {cloudConnectorName} +

+
+ + + + + {identifierLabel} + {': '} + {identifier} + + + {identifier && ( + + + {(copy) => ( + + )} + + + )} + + + + + {/* Edit Name Section */} + + + + + + + +
+ + + {/* Usage Section */} + +

+ +

+
+ + + + {error ? ( + + + + } + body={ +

+ +

+ } + data-test-subj={CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.ERROR_STATE} + /> + ) : isLoading ? ( + + ) : usageItems.length === 0 ? ( + + + + } + body={ +

+ +

+ } + data-test-subj={CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.EMPTY_STATE} + /> + ) : ( + + )} +
+
+ ); +}; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/form/cloud_connector_name_field.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/form/cloud_connector_name_field.tsx index 5247bb085c5e1..10bbc0de804b7 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/form/cloud_connector_name_field.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/form/cloud_connector_name_field.tsx @@ -8,45 +8,29 @@ import React from 'react'; import { EuiFormRow, EuiFieldText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { getCloudConnectorNameError } from '../utils'; interface CloudConnectorNameFieldProps { value: string; onChange: (name: string, isValid: boolean, validationError?: string) => void; disabled?: boolean; + 'data-test-subj'?: string; } export const CloudConnectorNameField: React.FC = ({ value, onChange, disabled = false, + 'data-test-subj': dataTestSubj, }) => { - // Format validation only - const validateFormat = (name: string): string | undefined => { - if (!name || !name.trim()) - return i18n.translate( - 'securitySolutionPackages.cloudSecurityPosture.cloudConnectorSetup.cloudConnectorNameField.requiredError', - { - defaultMessage: 'Cloud Connector Name is required', - } - ); - if (name.length > 255) - return i18n.translate( - 'securitySolutionPackages.cloudSecurityPosture.cloudConnectorSetup.cloudConnectorNameField.tooLongError', - { - defaultMessage: 'Cloud Connector Name must be 255 characters or less', - } - ); - return undefined; - }; - const handleChange = (e: React.ChangeEvent) => { const newValue = e.target.value; - const formatError = validateFormat(newValue); + const formatError = getCloudConnectorNameError(newValue); onChange(newValue, !formatError, formatError); }; - const error = validateFormat(value); + const error = getCloudConnectorNameError(value); return ( = ( isInvalid={!!error} disabled={disabled} fullWidth + data-test-subj={dataTestSubj} /> ); diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/form/cloud_connector_selector.test.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/form/cloud_connector_selector.test.tsx index 0bb696ddd066e..a7775a9efb335 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/form/cloud_connector_selector.test.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/form/cloud_connector_selector.test.tsx @@ -9,298 +9,183 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { I18nProvider } from '@kbn/i18n-react'; -import type { CloudProvider } from '@kbn/fleet-plugin/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { QueryClient, QueryClientProvider } from '@kbn/react-query'; import { AWS_CLOUD_CONNECTOR_SUPER_SELECT_TEST_SUBJ, - AZURE_CLOUD_CONNECTOR_SUPER_SELECT_TEST_SUBJ, + getCloudConnectorEditIconTestSubj, } from '@kbn/cloud-security-posture-common'; import { CloudConnectorSelector } from './cloud_connector_selector'; -import type { AwsCloudConnectorCredentials, AzureCloudConnectorCredentials } from '../types'; +import { useGetCloudConnectors } from '../hooks/use_get_cloud_connectors'; -// Mock the useGetCloudConnectors hook +jest.mock('@kbn/kibana-react-plugin/public'); jest.mock('../hooks/use_get_cloud_connectors'); -interface UseGetCloudConnectorsReturn { - data: - | Array<{ - id: string; - name: string; - vars: Record; - }> - | undefined; - isLoading: boolean; -} - -const mockUseGetCloudConnectors = jest.requireMock('../hooks/use_get_cloud_connectors') - .useGetCloudConnectors as jest.MockedFunction< - (provider?: CloudProvider) => UseGetCloudConnectorsReturn +const mockUseKibana = useKibana as jest.MockedFunction; +const mockUseGetCloudConnectors = useGetCloudConnectors as jest.MockedFunction< + typeof useGetCloudConnectors >; -// Helper to render with I18n provider -const renderWithIntl = (component: React.ReactElement) => { - return render({component}); -}; - -// Mock cloud connector data for AWS -const mockAwsCloudConnectors = [ - { - id: 'aws-connector-1', - name: 'AWS Connector 1', - vars: { - role_arn: { value: 'arn:aws:iam::123456789012:role/Role1' }, - external_id: { value: 'external-id-123' }, - }, - }, - { - id: 'aws-connector-2', - name: 'AWS Connector 2', - vars: { - role_arn: { value: 'arn:aws:iam::123456789012:role/Role2' }, - external_id: { value: 'external-id-456' }, - }, - }, -]; - -// Mock cloud connector data for Azure -const mockAzureCloudConnectors = [ - { - id: 'azure-connector-1', - name: 'Azure Connector 1', - vars: { - tenant_id: { value: 'tenant-123' }, - client_id: { value: 'client-456' }, - azure_credentials_cloud_connector_id: { value: 'azure-cc-789' }, - }, - }, -]; - describe('CloudConnectorSelector', () => { + let queryClient: QueryClient; const mockSetCredentials = jest.fn(); + const mockCloudConnectors = [ + { + id: 'connector-1', + name: 'AWS Connector 1', + cloudProvider: 'aws', + vars: { + role_arn: { value: 'arn:aws:iam::123456789012:role/Role1' }, + external_id: { value: 'external-id-1' }, + }, + packagePolicyCount: 2, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-02T00:00:00Z', + }, + { + id: 'connector-2', + name: 'AWS Connector 2', + cloudProvider: 'aws', + vars: { + role_arn: { value: 'arn:aws:iam::123456789012:role/Role2' }, + external_id: { value: 'external-id-2' }, + }, + packagePolicyCount: 1, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-02T00:00:00Z', + }, + ]; + beforeEach(() => { - jest.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + mockUseKibana.mockReturnValue({ + services: { + application: { + navigateToApp: jest.fn(), + }, + }, + } as unknown as ReturnType); + + mockUseGetCloudConnectors.mockReturnValue({ + data: mockCloudConnectors, + isLoading: false, + error: null, + } as unknown as ReturnType); + + mockSetCredentials.mockClear(); }); - describe('AWS Provider', () => { - const awsProps = { - provider: 'aws' as CloudProvider, + afterEach(() => { + queryClient.clear(); + }); + + const renderSelector = (props = {}) => { + const defaultProps = { + provider: 'aws' as const, cloudConnectorId: undefined, - credentials: { - roleArn: undefined, - externalId: undefined, - cloudConnectorId: undefined, - } as AwsCloudConnectorCredentials, + credentials: {}, setCredentials: mockSetCredentials, + ...props, }; - beforeEach(() => { - mockUseGetCloudConnectors.mockReturnValue({ - data: mockAwsCloudConnectors, - isLoading: false, - }); - }); - - it('displays AWS cloud connectors as options', async () => { - renderWithIntl(); - - const select = screen.getByTestId(AWS_CLOUD_CONNECTOR_SUPER_SELECT_TEST_SUBJ); - await userEvent.click(select); - - await waitFor(() => { - expect(screen.getByText('AWS Connector 1')).toBeInTheDocument(); - expect(screen.getByText('AWS Connector 2')).toBeInTheDocument(); - }); - }); - - it('calls setCredentials with correct AWS values when connector is selected', async () => { - renderWithIntl(); + return render( + + + + + + ); + }; - const select = screen.getByTestId(AWS_CLOUD_CONNECTOR_SUPER_SELECT_TEST_SUBJ); - await userEvent.click(select); + it('should render cloud connector selector', () => { + renderSelector(); - await waitFor(() => { - expect(screen.getByText('AWS Connector 1')).toBeInTheDocument(); - }); - - await userEvent.click(screen.getByText('AWS Connector 1')); - - expect(mockSetCredentials).toHaveBeenCalledWith({ - roleArn: 'arn:aws:iam::123456789012:role/Role1', - externalId: 'external-id-123', - cloudConnectorId: 'aws-connector-1', - }); - }); + expect(screen.getByText('Cloud Connector Name')).toBeInTheDocument(); + }); - it('displays selected AWS connector', async () => { - const propsWithSelection = { - ...awsProps, - cloudConnectorId: 'aws-connector-1', - }; + it('should display connectors in dropdown', async () => { + const user = userEvent.setup(); + renderSelector(); - renderWithIntl(); + const selector = screen.getByTestId(AWS_CLOUD_CONNECTOR_SUPER_SELECT_TEST_SUBJ); + await user.click(selector); - await waitFor(() => { - expect(screen.getByText('AWS Connector 1')).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByText('AWS Connector 1')).toBeInTheDocument(); + expect(screen.getByText('AWS Connector 2')).toBeInTheDocument(); }); }); - describe('Azure Provider', () => { - const azureProps = { - provider: 'azure' as CloudProvider, - cloudConnectorId: undefined, - credentials: { - tenantId: undefined, - clientId: undefined, - azure_credentials_cloud_connector_id: undefined, - cloudConnectorId: undefined, - } as AzureCloudConnectorCredentials, - setCredentials: mockSetCredentials, - }; - - beforeEach(() => { - mockUseGetCloudConnectors.mockReturnValue({ - data: mockAzureCloudConnectors, - isLoading: false, - }); + it('should display edit icon when connector is selected', () => { + renderSelector({ + cloudConnectorId: 'connector-1', }); - it('displays Azure cloud connectors as options', async () => { - renderWithIntl(); - - const select = screen.getByTestId(AZURE_CLOUD_CONNECTOR_SUPER_SELECT_TEST_SUBJ); - await userEvent.click(select); + expect( + screen.getByTestId(getCloudConnectorEditIconTestSubj('connector-1')) + ).toBeInTheDocument(); + }); - await waitFor(() => { - expect(screen.getByText('Azure Connector 1')).toBeInTheDocument(); - }); + it('should open flyout when clicking edit icon on selected connector', async () => { + const user = userEvent.setup(); + renderSelector({ + cloudConnectorId: 'connector-1', }); - it('calls setCredentials with correct Azure values when connector is selected', async () => { - renderWithIntl(); + const editIcon = screen.getByTestId(getCloudConnectorEditIconTestSubj('connector-1')); + await user.click(editIcon); - const select = screen.getByTestId(AZURE_CLOUD_CONNECTOR_SUPER_SELECT_TEST_SUBJ); - await userEvent.click(select); + await waitFor(() => { + expect(screen.getByTestId('cloudConnectorPoliciesFlyout')).toBeInTheDocument(); + }); + }); - await waitFor(() => { - expect(screen.getByText('Azure Connector 1')).toBeInTheDocument(); - }); + it('should call setCredentials when selecting a connector', async () => { + const user = userEvent.setup(); + renderSelector(); - await userEvent.click(screen.getByText('Azure Connector 1')); + const selector = screen.getByTestId(AWS_CLOUD_CONNECTOR_SUPER_SELECT_TEST_SUBJ); + await user.click(selector); - expect(mockSetCredentials).toHaveBeenCalledWith({ - tenantId: 'tenant-123', - clientId: 'client-456', - azure_credentials_cloud_connector_id: 'azure-cc-789', - cloudConnectorId: 'azure-connector-1', - }); + await waitFor(() => { + expect(screen.getByText('AWS Connector 1')).toBeInTheDocument(); }); - it('displays selected Azure connector', async () => { - const propsWithSelection = { - ...azureProps, - cloudConnectorId: 'azure-connector-1', - }; - - renderWithIntl(); + await user.click(screen.getByText('AWS Connector 1')); - await waitFor(() => { - expect(screen.getByText('Azure Connector 1')).toBeInTheDocument(); - }); + expect(mockSetCredentials).toHaveBeenCalledWith({ + roleArn: 'arn:aws:iam::123456789012:role/Role1', + externalId: 'external-id-1', + cloudConnectorId: 'connector-1', }); }); - describe('Hook Integration', () => { - it('calls useGetCloudConnectors with correct AWS provider', () => { - mockUseGetCloudConnectors.mockReturnValue({ - data: mockAwsCloudConnectors, - isLoading: false, - }); - - const props = { - provider: 'aws' as CloudProvider, - cloudConnectorId: undefined, - credentials: { - roleArn: undefined, - externalId: undefined, - cloudConnectorId: undefined, - } as AwsCloudConnectorCredentials, - setCredentials: mockSetCredentials, - }; - - renderWithIntl(); - - expect(mockUseGetCloudConnectors).toHaveBeenCalledWith('aws'); + it('should display selected connector', () => { + renderSelector({ + cloudConnectorId: 'connector-1', }); - it('calls useGetCloudConnectors with correct Azure provider', () => { - mockUseGetCloudConnectors.mockReturnValue({ - data: mockAzureCloudConnectors, - isLoading: false, - }); - - const props = { - provider: 'azure' as CloudProvider, - cloudConnectorId: undefined, - credentials: { - tenantId: undefined, - clientId: undefined, - azure_credentials_cloud_connector_id: undefined, - cloudConnectorId: undefined, - } as AzureCloudConnectorCredentials, - setCredentials: mockSetCredentials, - }; - - renderWithIntl(); - - expect(mockUseGetCloudConnectors).toHaveBeenCalledWith('azure'); - }); + expect(screen.getByText('AWS Connector 1')).toBeInTheDocument(); + }); - it('renders with correct test subject for AWS provider', () => { - mockUseGetCloudConnectors.mockReturnValue({ - data: mockAwsCloudConnectors, - isLoading: false, - }); - - const props = { - provider: 'aws' as CloudProvider, - cloudConnectorId: undefined, - credentials: { - roleArn: undefined, - externalId: undefined, - cloudConnectorId: undefined, - } as AwsCloudConnectorCredentials, - setCredentials: mockSetCredentials, - }; - - renderWithIntl(); - - const select = screen.getByTestId(AWS_CLOUD_CONNECTOR_SUPER_SELECT_TEST_SUBJ); - expect(select).toBeInTheDocument(); + it('should not call setCredentials when clicking edit icon', async () => { + const user = userEvent.setup(); + renderSelector({ + cloudConnectorId: 'connector-1', }); - it('renders with correct test subject for Azure provider', () => { - mockUseGetCloudConnectors.mockReturnValue({ - data: mockAzureCloudConnectors, - isLoading: false, - }); - - const props = { - provider: 'azure' as CloudProvider, - cloudConnectorId: undefined, - credentials: { - tenantId: undefined, - clientId: undefined, - azure_credentials_cloud_connector_id: undefined, - cloudConnectorId: undefined, - } as AzureCloudConnectorCredentials, - setCredentials: mockSetCredentials, - }; - - renderWithIntl(); - - const select = screen.getByTestId(AZURE_CLOUD_CONNECTOR_SUPER_SELECT_TEST_SUBJ); - expect(select).toBeInTheDocument(); - }); + const editIcon = screen.getByTestId(getCloudConnectorEditIconTestSubj('connector-1')); + await user.click(editIcon); + + // Edit icon click should not trigger connector selection + expect(mockSetCredentials).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/form/cloud_connector_selector.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/form/cloud_connector_selector.tsx index 2fa2eb45cbf23..40da2ea66aca5 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/form/cloud_connector_selector.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/form/cloud_connector_selector.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo, useCallback } from 'react'; +import React, { useMemo, useCallback, useState } from 'react'; import { EuiSuperSelect, EuiFormRow, @@ -13,6 +13,8 @@ import { EuiFlexItem, EuiText, EuiTextTruncate, + EuiButtonIcon, + EuiToolTip, } from '@elastic/eui'; import type { EuiSuperSelectOption } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -20,10 +22,12 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { AWS_CLOUD_CONNECTOR_SUPER_SELECT_TEST_SUBJ, AZURE_CLOUD_CONNECTOR_SUPER_SELECT_TEST_SUBJ, + getCloudConnectorEditIconTestSubj, } from '@kbn/cloud-security-posture-common'; import type { CloudConnectorCredentials, CloudProviders } from '../types'; import { useGetCloudConnectors } from '../hooks/use_get_cloud_connectors'; import { isAwsCloudConnectorVars, isAzureCloudConnectorVars } from '../utils'; +import { CloudConnectorPoliciesFlyout } from '../cloud_connector_policies_flyout'; interface CloudConnectorSelectorProps { provider: CloudProviders; @@ -39,6 +43,8 @@ export const CloudConnectorSelector = ({ setCredentials, }: CloudConnectorSelectorProps) => { const { data: cloudConnectors = [] } = useGetCloudConnectors(provider); + const [flyoutConnectorId, setFlyoutConnectorId] = useState(null); + const [selectKey, setSelectKey] = useState(0); const label = ( ); + const handleOpenFlyout = useCallback((e: React.MouseEvent, connectorId: string) => { + e.stopPropagation(); + e.preventDefault(); + setFlyoutConnectorId(connectorId); + // Force dropdown to close by re-rendering the select + setSelectKey((prev) => prev + 1); + }, []); + + const handleCloseFlyout = useCallback(() => { + setFlyoutConnectorId(null); + }, []); + + // Find the connector for the flyout + const flyoutConnector = useMemo(() => { + return cloudConnectors.find((c) => c.id === flyoutConnectorId); + }, [cloudConnectors, flyoutConnectorId]); + // Create super select options with custom display const selectOptions: Array> = useMemo(() => { return cloudConnectors.map((connector) => { @@ -60,26 +83,63 @@ export const CloudConnectorSelector = ({ return { value: connector.id, - inputDisplay: connector.name, + inputDisplay: ( + + + + + + + ) => + handleOpenFlyout(e, connector.id) + } + data-test-subj={getCloudConnectorEditIconTestSubj(connector.id)} + /> + + + + ), dropdownDisplay: ( - + - - - + + + + + + + {identifier && ( + + + + + + )} + - {identifier && ( - - - - - - )} ), }; }); - }, [cloudConnectors, provider]); + }, [cloudConnectors, provider, handleOpenFlyout]); // Find currently selected value const selectedValue = useMemo(() => { @@ -124,21 +184,34 @@ export const CloudConnectorSelector = ({ : AZURE_CLOUD_CONNECTOR_SUPER_SELECT_TEST_SUBJ; return ( - - - + <> + + + + + {flyoutConnector && ( + + )} + ); }; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/hooks/use_cloud_connector_setup.test.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/hooks/use_cloud_connector_setup.test.ts index f0d8a570551f8..53a0784614bff 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/hooks/use_cloud_connector_setup.test.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/hooks/use_cloud_connector_setup.test.ts @@ -18,6 +18,11 @@ jest.mock('../utils', () => ({ updateInputVarsWithCredentials: jest.fn(), updatePolicyInputs: jest.fn(), isAzureCloudConnectorVars: jest.fn(), + isCloudConnectorNameValid: jest.fn((name: string | undefined) => { + if (!name) return false; + const trimmedLength = name.trim().length; + return trimmedLength > 0 && name.length <= 255; + }), })); import { diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/hooks/use_cloud_connector_setup.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/hooks/use_cloud_connector_setup.ts index 40fb0b3dc11c0..f7e124a80302b 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/hooks/use_cloud_connector_setup.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/hooks/use_cloud_connector_setup.ts @@ -17,6 +17,7 @@ import { isAzureCloudConnectorVars, updateInputVarsWithCredentials, updatePolicyInputs, + isCloudConnectorNameValid, } from '../utils'; import { AWS_CLOUD_CONNECTOR_FIELD_NAMES, AZURE_CLOUD_CONNECTOR_FIELD_NAMES } from '../constants'; @@ -107,10 +108,8 @@ export const useCloudConnectorSetup = ( const updatedPolicy = { ...newPolicy }; const inputVars = input.streams?.find((i) => i.enabled)?.vars; - // Determine if name is valid (format only) mimicking the API schema validation - // Check for trimmed name to prevent whitespace-only strings - const isNameValid = - credentials.name && credentials.name.trim().length > 0 && credentials.name.length <= 255; + // Use shared validation utility for name validation + const isNameValid = isCloudConnectorNameValid(credentials.name); // Set cloud_connector_name directly on the policy object (not in input vars) updatedPolicy.cloud_connector_name = credentials.name; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/hooks/use_cloud_connector_usage.test.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/hooks/use_cloud_connector_usage.test.ts new file mode 100644 index 0000000000000..7417ad1dad6d0 --- /dev/null +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/hooks/use_cloud_connector_usage.test.ts @@ -0,0 +1,123 @@ +/* + * 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 { renderHook, waitFor } from '@testing-library/react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { CLOUD_CONNECTOR_API_ROUTES } from '@kbn/fleet-plugin/public'; +import { QueryClient, QueryClientProvider } from '@kbn/react-query'; +import React from 'react'; +import { useCloudConnectorUsage } from './use_cloud_connector_usage'; + +jest.mock('@kbn/kibana-react-plugin/public'); + +const mockHttp = { + get: jest.fn(), +}; + +const mockUseKibana = useKibana as jest.MockedFunction; + +describe('useCloudConnectorUsage', () => { + let queryClient: QueryClient; + + beforeEach(() => { + // Suppress console.error for expected error tests + jest.spyOn(console, 'error').mockImplementation(() => {}); + + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + mockUseKibana.mockReturnValue({ + services: { + http: mockHttp, + }, + } as unknown as ReturnType); + + mockHttp.get.mockClear(); + }); + + afterEach(() => { + queryClient.clear(); + jest.restoreAllMocks(); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + it('should fetch cloud connector usage', async () => { + const mockUsageData = { + items: [ + { + id: 'policy-1', + name: 'Test Policy 1', + package: { + name: 'cloud_security_posture', + title: 'Cloud Security Posture', + version: '1.0.0', + }, + policy_ids: ['agent-policy-1'], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-02T00:00:00Z', + }, + ], + }; + + mockHttp.get.mockResolvedValue(mockUsageData); + + const { result } = renderHook(() => useCloudConnectorUsage('connector-id-123'), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockHttp.get).toHaveBeenCalledWith( + CLOUD_CONNECTOR_API_ROUTES.USAGE_PATTERN.replace('{cloudConnectorId}', 'connector-id-123'), + { query: { page: 1, perPage: 10 } } + ); + expect(result.current.data).toEqual(mockUsageData); + }); + + it('should handle errors', async () => { + const mockError = new Error('Failed to fetch usage'); + mockHttp.get.mockRejectedValue(mockError); + + const { result } = renderHook(() => useCloudConnectorUsage('connector-id-123'), { wrapper }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(mockError); + }); + + it('should not fetch when cloudConnectorId is empty', () => { + const { result } = renderHook(() => useCloudConnectorUsage(''), { wrapper }); + + expect(mockHttp.get).not.toHaveBeenCalled(); + expect(result.current.data).toBeUndefined(); + }); + + it('should throw error when http service is not available', async () => { + mockUseKibana.mockReturnValue({ + services: { + http: undefined, + }, + } as unknown as ReturnType); + + const { result } = renderHook(() => useCloudConnectorUsage('connector-id-123'), { wrapper }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(new Error('HTTP service is not available')); + }); +}); diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/hooks/use_cloud_connector_usage.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/hooks/use_cloud_connector_usage.ts new file mode 100644 index 0000000000000..3fc13aa03ad48 --- /dev/null +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/hooks/use_cloud_connector_usage.ts @@ -0,0 +1,75 @@ +/* + * 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 '@kbn/react-query'; +import { CLOUD_CONNECTOR_API_ROUTES } from '@kbn/fleet-plugin/public'; +import type { HttpStart } from '@kbn/core/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; + +export interface CloudConnectorUsageItem { + id: string; + name: string; + package?: { + name: string; + title: string; + version: string; + }; + policy_ids: string[]; + created_at: string; + updated_at: string; +} + +interface CloudConnectorUsageResponse { + items: CloudConnectorUsageItem[]; + total: number; + page: number; + perPage: number; +} + +const fetchCloudConnectorUsage = async ( + http: HttpStart, + cloudConnectorId: string, + page: number, + perPage: number +): Promise => { + const path = CLOUD_CONNECTOR_API_ROUTES.USAGE_PATTERN.replace( + '{cloudConnectorId}', + cloudConnectorId + ); + + return http.get(path, { + query: { + page, + perPage, + }, + }); +}; + +export const useCloudConnectorUsage = ( + cloudConnectorId: string, + page: number = 1, + perPage: number = 10 +) => { + const CLOUD_CONNECTOR_USAGE_QUERY_KEY = 'cloud-connector-usage'; + const { http } = useKibana().services; + + return useQuery( + [CLOUD_CONNECTOR_USAGE_QUERY_KEY, cloudConnectorId, page, perPage], + () => { + if (!http) { + throw new Error('HTTP service is not available'); + } + return fetchCloudConnectorUsage(http, cloudConnectorId, page, perPage); + }, + { + enabled: !!cloudConnectorId, + keepPreviousData: true, // Keep previous data to avoid flashing when going through pages + staleTime: 60000, // Cache for 1 minute + refetchOnWindowFocus: false, + } + ); +}; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/hooks/use_update_cloud_connector.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/hooks/use_update_cloud_connector.ts new file mode 100644 index 0000000000000..bdbf6f85ee13c --- /dev/null +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/hooks/use_update_cloud_connector.ts @@ -0,0 +1,100 @@ +/* + * 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 { useMutation, useQueryClient } from '@kbn/react-query'; +import { CLOUD_CONNECTOR_API_ROUTES } from '@kbn/fleet-plugin/public'; +import type { HttpStart } from '@kbn/core/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { i18n } from '@kbn/i18n'; + +interface UpdateCloudConnectorRequest { + name?: string; +} + +interface CloudConnector { + id: string; + name: string; + namespace?: string; + cloudProvider: string; + vars: Record; + packagePolicyCount: number; + created_at: string; + updated_at: string; +} + +interface UpdateCloudConnectorResponse { + item: CloudConnector; +} + +const updateCloudConnector = async ( + http: HttpStart, + cloudConnectorId: string, + data: UpdateCloudConnectorRequest +): Promise => { + const path = CLOUD_CONNECTOR_API_ROUTES.UPDATE_PATTERN.replace( + '{cloudConnectorId}', + cloudConnectorId + ); + + return http + .put(path, { + body: JSON.stringify(data), + }) + .then((res: UpdateCloudConnectorResponse) => res.item); +}; + +export const useUpdateCloudConnector = ( + cloudConnectorId: string, + onSuccess?: (connector: CloudConnector) => void, + onError?: (error: Error) => void +) => { + const { http, notifications } = useKibana().services; + const queryClient = useQueryClient(); + + return useMutation( + (data: UpdateCloudConnectorRequest) => { + if (!http) { + throw new Error('HTTP service is not available'); + } + return updateCloudConnector(http, cloudConnectorId, data); + }, + { + onSuccess: (connector) => { + // Invalidate relevant queries + queryClient.invalidateQueries(['get-cloud-connectors']); + queryClient.invalidateQueries(['cloud-connector-usage', cloudConnectorId]); + + notifications?.toasts.addSuccess({ + title: i18n.translate( + 'securitySolutionPackages.cloudSecurityPosture.cloudConnector.updateSuccess', + { + defaultMessage: 'Cloud connector updated successfully', + } + ), + }); + + if (onSuccess) { + onSuccess(connector); + } + }, + onError: (error: Error) => { + notifications?.toasts.addError(error, { + title: i18n.translate( + 'securitySolutionPackages.cloudSecurityPosture.cloudConnector.updateError', + { + defaultMessage: 'Failed to update cloud connector', + } + ), + }); + + if (onError) { + onError(error); + } + }, + } + ); +}; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/utils.test.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/utils.test.ts index 45d9ac7fff052..278c0c7f396a0 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/utils.test.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/utils.test.ts @@ -15,6 +15,9 @@ import { getCloudConnectorRemoteRoleTemplate, getKibanaComponentId, getDeploymentIdFromUrl, + getCloudConnectorNameError, + isCloudConnectorNameValid, + CLOUD_CONNECTOR_NAME_MAX_LENGTH, } from './utils'; import { getMockPolicyAWS, getMockPackageInfoAWS } from './test/mock'; import type { @@ -1405,3 +1408,96 @@ describe('getDeploymentIdFromUrl', () => { expect(result).toBe('deployment-abc-123-xyz'); }); }); + +describe('Cloud Connector Name Validation', () => { + describe('CLOUD_CONNECTOR_NAME_MAX_LENGTH', () => { + it('should be 255', () => { + expect(CLOUD_CONNECTOR_NAME_MAX_LENGTH).toBe(255); + }); + }); + + describe('isCloudConnectorNameValid', () => { + it('should return false for undefined name', () => { + expect(isCloudConnectorNameValid(undefined)).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isCloudConnectorNameValid('')).toBe(false); + }); + + it('should return false for whitespace-only string', () => { + expect(isCloudConnectorNameValid(' ')).toBe(false); + expect(isCloudConnectorNameValid('\t\n')).toBe(false); + }); + + it('should return true for valid name', () => { + expect(isCloudConnectorNameValid('my-connector')).toBe(true); + expect(isCloudConnectorNameValid('Test Connector 123')).toBe(true); + }); + + it('should return true for name with exactly 255 characters', () => { + const exactly255Chars = 'a'.repeat(255); + expect(isCloudConnectorNameValid(exactly255Chars)).toBe(true); + }); + + it('should return false for name exceeding 255 characters', () => { + const chars256 = 'a'.repeat(256); + expect(isCloudConnectorNameValid(chars256)).toBe(false); + }); + + it('should handle boundary cases correctly', () => { + expect(isCloudConnectorNameValid('a'.repeat(254))).toBe(true); + expect(isCloudConnectorNameValid('a'.repeat(255))).toBe(true); + expect(isCloudConnectorNameValid('a'.repeat(256))).toBe(false); + expect(isCloudConnectorNameValid('a'.repeat(257))).toBe(false); + }); + + it('should handle special characters', () => { + expect(isCloudConnectorNameValid('my-connector_123!@#')).toBe(true); + }); + + it('should handle unicode characters', () => { + expect(isCloudConnectorNameValid('connector-中文-名称')).toBe(true); + }); + }); + + describe('getCloudConnectorNameError', () => { + it('should return required error for undefined name', () => { + expect(getCloudConnectorNameError(undefined)).toBe('Cloud Connector Name is required'); + }); + + it('should return required error for empty string', () => { + expect(getCloudConnectorNameError('')).toBe('Cloud Connector Name is required'); + }); + + it('should return required error for whitespace-only string', () => { + expect(getCloudConnectorNameError(' ')).toBe('Cloud Connector Name is required'); + expect(getCloudConnectorNameError('\t\n')).toBe('Cloud Connector Name is required'); + }); + + it('should return undefined for valid name', () => { + expect(getCloudConnectorNameError('my-connector')).toBeUndefined(); + expect(getCloudConnectorNameError('Test Connector 123')).toBeUndefined(); + }); + + it('should return undefined for name with exactly 255 characters', () => { + const exactly255Chars = 'a'.repeat(255); + expect(getCloudConnectorNameError(exactly255Chars)).toBeUndefined(); + }); + + it('should return length error for name exceeding 255 characters', () => { + const chars256 = 'a'.repeat(256); + expect(getCloudConnectorNameError(chars256)).toBe( + 'Cloud Connector Name must be 255 characters or less' + ); + }); + + it('should handle boundary cases correctly', () => { + expect(getCloudConnectorNameError('a'.repeat(254))).toBeUndefined(); + expect(getCloudConnectorNameError('a'.repeat(255))).toBeUndefined(); + expect(getCloudConnectorNameError('a'.repeat(256))).toBe( + 'Cloud Connector Name must be 255 characters or less' + ); + }); + }); +}); diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/utils.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/utils.ts index db598c7f723a4..77a7b94a8b566 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/utils.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/cloud_connector/utils.ts @@ -13,6 +13,7 @@ import type { PackagePolicyConfigRecord, } from '@kbn/fleet-plugin/common'; import semver from 'semver'; +import { i18n } from '@kbn/i18n'; import type { AwsCloudConnectorVars, @@ -51,6 +52,46 @@ export type AzureCloudConnectorFieldNames = export type AwsCloudConnectorFieldNames = (typeof AWS_CLOUD_CONNECTOR_FIELD_NAMES)[keyof typeof AWS_CLOUD_CONNECTOR_FIELD_NAMES]; +// Cloud connector name validation constants +export const CLOUD_CONNECTOR_NAME_MAX_LENGTH = 255; + +/** + * Validates a cloud connector name + * @param name - The name to validate + * @returns true if the name is valid, false otherwise + */ +export const isCloudConnectorNameValid = (name: string | undefined): boolean => { + if (!name) return false; + const trimmedLength = name.trim().length; + return trimmedLength > 0 && name.length <= CLOUD_CONNECTOR_NAME_MAX_LENGTH; +}; + +/** + * Gets the validation error message for a cloud connector name + * @param name - The name to validate + * @returns Error message string or undefined if valid + */ +export const getCloudConnectorNameError = (name: string | undefined): string | undefined => { + if (!name || name.trim().length === 0) { + return i18n.translate( + 'securitySolutionPackages.cloudSecurityPosture.cloudConnectorNameValidation.requiredError', + { + defaultMessage: 'Cloud Connector Name is required', + } + ); + } + if (name.length > CLOUD_CONNECTOR_NAME_MAX_LENGTH) { + return i18n.translate( + 'securitySolutionPackages.cloudSecurityPosture.cloudConnectorNameValidation.tooLongError', + { + defaultMessage: 'Cloud Connector Name must be {maxLength} characters or less', + values: { maxLength: CLOUD_CONNECTOR_NAME_MAX_LENGTH }, + } + ); + } + return undefined; +}; + export const isAwsCloudConnectorVars = ( vars: CloudConnectorVars, provider: string