[Security Solution][Endpoint] Add validations to Update and Delete artifact APIs in support of spaces (#212308)

## Summary

Adds additional validations to Artifact APIs _(via `lists` plugin
server-side extension points)_ for the following conditions:

- If user has the global artifact management privilege, then they are
able to update/delete the artifact with no restriction (same as today)
- If user does NOT have the new global artifact management privilege,
then the update/delete action should fail:
    - If it's a global artifact
- If it's a per policy artifact but it was created from a different
space than the active space the API is being called from


> [!NOTE]
> Functionality is currently behind the following feature flag:
`endpointManagementSpaceAwarenessEnabled`



### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
This commit is contained in:
Paul Tavares 2025-02-28 14:35:26 -05:00 committed by GitHub
parent 0a562628b6
commit 7e79844925
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 573 additions and 37 deletions

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { getExceptionListItemSchemaMock } from '../common/schemas/response/exception_list_item_schema.mock';
import { ListPluginSetup } from './types';
import { getListClientMock } from './services/lists/list_client.mock';
import {
@ -25,5 +27,6 @@ export const listMock = {
createSetup: createSetupMock,
getCreateExceptionListItemOptionsMock,
getExceptionListClient: getExceptionListClientMock,
getExceptionListItemSchemaMock,
getListClient: getListClientMock,
};

View file

@ -40,7 +40,9 @@ export const getExceptionsPreDeleteItemHandler = (
// Validate Trusted Applications
if (TrustedAppValidator.isTrustedApp({ listId })) {
await new TrustedAppValidator(endpointAppContextService, request).validatePreDeleteItem();
await new TrustedAppValidator(endpointAppContextService, request).validatePreDeleteItem(
exceptionItem
);
return data;
}
@ -49,19 +51,23 @@ export const getExceptionsPreDeleteItemHandler = (
await new HostIsolationExceptionsValidator(
endpointAppContextService,
request
).validatePreDeleteItem();
).validatePreDeleteItem(exceptionItem);
return data;
}
// Event Filter validation
if (EventFilterValidator.isEventFilter({ listId })) {
await new EventFilterValidator(endpointAppContextService, request).validatePreDeleteItem();
await new EventFilterValidator(endpointAppContextService, request).validatePreDeleteItem(
exceptionItem
);
return data;
}
// Validate Blocklists
if (BlocklistValidator.isBlocklist({ listId })) {
await new BlocklistValidator(endpointAppContextService, request).validatePreDeleteItem();
await new BlocklistValidator(endpointAppContextService, request).validatePreDeleteItem(
exceptionItem
);
return data;
}
@ -70,7 +76,7 @@ export const getExceptionsPreDeleteItemHandler = (
await new EndpointExceptionsValidator(
endpointAppContextService,
request
).validatePreDeleteItem();
).validatePreDeleteItem(exceptionItem);
return data;
}

View file

@ -11,7 +11,11 @@ import {
createMockEndpointAppContextServiceSetupContract,
createMockEndpointAppContextServiceStartContract,
} from '../../../endpoint/mocks';
import { BaseValidatorMock, createExceptionItemLikeOptionsMock } from './mocks';
import {
BaseValidatorMock,
createExceptionItemLikeOptionsMock,
createExceptionListItemMock,
} from './mocks';
import { EndpointArtifactExceptionValidationError } from './errors';
import { httpServerMock } from '@kbn/core/server/mocks';
import type { PackagePolicy } from '@kbn/fleet-plugin/common';
@ -23,10 +27,15 @@ import {
GLOBAL_ARTIFACT_TAG,
} from '../../../../common/endpoint/service/artifacts';
import { securityMock } from '@kbn/security-plugin/server/mocks';
import { setArtifactOwnerSpaceId } from '../../../../common/endpoint/service/artifacts/utils';
import {
buildPerPolicyTag,
buildSpaceOwnerIdTag,
setArtifactOwnerSpaceId,
} from '../../../../common/endpoint/service/artifacts/utils';
import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
import { getEndpointAuthzInitialStateMock } from '../../../../common/endpoint/service/authz/mocks';
import type { EndpointAuthz } from '../../../../common/endpoint/types/authz';
import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
describe('When using Artifacts Exceptions BaseValidator', () => {
let endpointAppContextServices: EndpointAppContextService;
@ -198,16 +207,23 @@ describe('When using Artifacts Exceptions BaseValidator', () => {
});
describe('with space awareness', () => {
const noGlobalArtifactManagementAuthzMessage =
const noAuthzToManageOwnerSpaceIdError =
'EndpointArtifactError: Endpoint authorization failure. Management of "ownerSpaceId" tag requires global artifact management privilege';
const noAuthzToManageGlobalArtifactsError =
'EndpointArtifactError: Endpoint authorization failure. Management of global artifacts requires additional privilege (global artifact management)';
const itemCanNotBeManagedInActiveSpaceErrorMessage =
'EndpointArtifactError: Updates to this shared item can only be done from the following space ID: foo (or by someone having global artifact management privilege)';
const setSpaceAwarenessFeatureFlag = (value: 'enabled' | 'disabled'): void => {
// @ts-expect-error updating a readonly field
endpointAppContextServices.experimentalFeatures.endpointManagementSpaceAwarenessEnabled =
value === 'enabled';
};
let authzMock: EndpointAuthz;
beforeEach(() => {
authzMock = getEndpointAuthzInitialStateMock();
endpointAppContextServices = createMockEndpointAppContextService();
// @ts-expect-error updating a readonly field
endpointAppContextServices.experimentalFeatures.endpointManagementSpaceAwarenessEnabled =
true;
setSpaceAwarenessFeatureFlag('enabled');
(endpointAppContextServices.getEndpointAuthz as jest.Mock).mockResolvedValue(authzMock);
setArtifactOwnerSpaceId(exceptionLikeItem, DEFAULT_SPACE_ID);
validator = new BaseValidatorMock(endpointAppContextServices, kibanaRequest);
@ -219,7 +235,7 @@ describe('When using Artifacts Exceptions BaseValidator', () => {
authzMock.canManageGlobalArtifacts = false;
await expect(validator._validateCreateOwnerSpaceIds(exceptionLikeItem)).rejects.toThrow(
noGlobalArtifactManagementAuthzMessage
noAuthzToManageOwnerSpaceIdError
);
});
@ -240,9 +256,7 @@ describe('When using Artifacts Exceptions BaseValidator', () => {
});
it('should not error if feature flag is disabled', async () => {
// @ts-expect-error updating a readonly field
endpointAppContextServices.experimentalFeatures.endpointManagementSpaceAwarenessEnabled =
false;
setSpaceAwarenessFeatureFlag('disabled');
authzMock.canManageGlobalArtifacts = false;
setArtifactOwnerSpaceId(exceptionLikeItem, 'foo');
setArtifactOwnerSpaceId(exceptionLikeItem, 'bar');
@ -267,7 +281,7 @@ describe('When using Artifacts Exceptions BaseValidator', () => {
await expect(
validator._validateUpdateOwnerSpaceIds(exceptionLikeItem, savedExceptionLikeItem)
).rejects.toThrow(noGlobalArtifactManagementAuthzMessage);
).rejects.toThrow(noAuthzToManageOwnerSpaceIdError);
});
it('should allow changes to spaceOwnerId tags if user has global artifact management authz', async () => {
@ -279,9 +293,7 @@ describe('When using Artifacts Exceptions BaseValidator', () => {
});
it('should not error if feature flag is disabled', async () => {
// @ts-expect-error updating a readonly field
endpointAppContextServices.experimentalFeatures.endpointManagementSpaceAwarenessEnabled =
false;
setSpaceAwarenessFeatureFlag('disabled');
authzMock.canManageGlobalArtifacts = false;
setArtifactOwnerSpaceId(exceptionLikeItem, 'foo');
setArtifactOwnerSpaceId(exceptionLikeItem, 'bar');
@ -291,5 +303,154 @@ describe('When using Artifacts Exceptions BaseValidator', () => {
).resolves.toBeUndefined();
});
});
describe('#validateCanCreateGlobalArtifacts()', () => {
beforeEach(() => {
exceptionLikeItem.tags = [GLOBAL_ARTIFACT_TAG];
});
it('should do nothing if feature flag is turned off', async () => {
authzMock.canManageGlobalArtifacts = false;
setSpaceAwarenessFeatureFlag('disabled');
await expect(
validator._validateCanCreateGlobalArtifacts(exceptionLikeItem)
).resolves.toBeUndefined();
});
it('should error is user does not have new global artifact management privilege', async () => {
authzMock.canManageGlobalArtifacts = false;
await expect(
validator._validateCanCreateGlobalArtifacts(exceptionLikeItem)
).rejects.toThrow(noAuthzToManageGlobalArtifactsError);
});
it('should allow creation of global artifacts when user has privilege', async () => {
await expect(
validator._validateCanCreateGlobalArtifacts(exceptionLikeItem)
).resolves.toBeUndefined();
});
});
describe('#validateCanUpdateItemInActiveSpace()', () => {
let savedExceptionItem: ExceptionListItemSchema;
beforeEach(() => {
savedExceptionItem = createExceptionListItemMock({
// Saved item is owned by different space id
tags: [buildPerPolicyTag('123'), buildSpaceOwnerIdTag('foo')],
});
});
it('should do nothing if feature flag is turned off', async () => {
setSpaceAwarenessFeatureFlag('disabled');
authzMock.canManageGlobalArtifacts = false;
await expect(
validator._validateCanUpdateItemInActiveSpace(exceptionLikeItem, savedExceptionItem)
).resolves.toBeUndefined();
});
it('should error if updating a global item when user does not have global artifact privilege', async () => {
authzMock.canManageGlobalArtifacts = false;
savedExceptionItem.tags = [GLOBAL_ARTIFACT_TAG, buildSpaceOwnerIdTag('foo')];
await expect(
validator._validateCanUpdateItemInActiveSpace(exceptionLikeItem, savedExceptionItem)
).rejects.toThrow(noAuthzToManageGlobalArtifactsError);
});
it('should error if updating an item outside of its owner space id when user does not have global artifact privilege', async () => {
authzMock.canManageGlobalArtifacts = false;
await expect(
validator._validateCanUpdateItemInActiveSpace(exceptionLikeItem, savedExceptionItem)
).rejects.toThrow(itemCanNotBeManagedInActiveSpaceErrorMessage);
});
it('should allow updates to global items when user has global artifact privilege', async () => {
savedExceptionItem.tags = [GLOBAL_ARTIFACT_TAG, buildSpaceOwnerIdTag('foo')];
await expect(
validator._validateCanUpdateItemInActiveSpace(exceptionLikeItem, savedExceptionItem)
).resolves.toBeUndefined();
});
it('should allow update to item outside of owner space id when user has global artifact privilege', async () => {
await expect(
validator._validateCanUpdateItemInActiveSpace(exceptionLikeItem, savedExceptionItem)
).resolves.toBeUndefined();
});
it('should allow update to item inside of owner space id when user has no global artifact privilege', async () => {
authzMock.canManageGlobalArtifacts = false;
savedExceptionItem.tags = [buildPerPolicyTag('123'), buildSpaceOwnerIdTag('default')];
await expect(
validator._validateCanUpdateItemInActiveSpace(exceptionLikeItem, savedExceptionItem)
).resolves.toBeUndefined();
});
});
describe('#validateCanDeleteItemInActiveSpace()', () => {
let savedExceptionItem: ExceptionListItemSchema;
beforeEach(() => {
savedExceptionItem = createExceptionListItemMock({
// Saved item is owned by different space id
tags: [buildPerPolicyTag('123'), buildSpaceOwnerIdTag('foo')],
});
});
it('should do nothing if feature flag is turned off', async () => {
authzMock.canManageGlobalArtifacts = false;
setSpaceAwarenessFeatureFlag('disabled');
await expect(
validator._validateCanDeleteItemInActiveSpace(savedExceptionItem)
).resolves.toBeUndefined();
});
it('should error if deleting a global artifact when user does not have global artifact privilege', async () => {
authzMock.canManageGlobalArtifacts = false;
savedExceptionItem.tags = [GLOBAL_ARTIFACT_TAG, buildSpaceOwnerIdTag('foo')];
await expect(
validator._validateCanDeleteItemInActiveSpace(savedExceptionItem)
).rejects.toThrow(noAuthzToManageGlobalArtifactsError);
});
it('should error if deleting item outside of its owner space id when user does not have global artifact privilege', async () => {
authzMock.canManageGlobalArtifacts = false;
await expect(
validator._validateCanDeleteItemInActiveSpace(savedExceptionItem)
).rejects.toThrow(itemCanNotBeManagedInActiveSpaceErrorMessage);
});
it('should allow delete of global item when user has global artifact privilege', async () => {
savedExceptionItem.tags = [GLOBAL_ARTIFACT_TAG, buildSpaceOwnerIdTag('foo')];
await expect(
validator._validateCanDeleteItemInActiveSpace(savedExceptionItem)
).resolves.toBeUndefined();
});
it('should allow deleting item from outside of its owner space id when user has global artifact privilege', async () => {
await expect(
validator._validateCanDeleteItemInActiveSpace(savedExceptionItem)
).resolves.toBeUndefined();
});
it('should allow deleting of item inside from owner space id when user has no global artifact privilege', async () => {
authzMock.canManageGlobalArtifacts = false;
savedExceptionItem.tags = [buildPerPolicyTag('123'), buildSpaceOwnerIdTag('default')];
await expect(
validator._validateCanDeleteItemInActiveSpace(savedExceptionItem)
).resolves.toBeUndefined();
});
});
});
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { KibanaRequest } from '@kbn/core/server';
import type { KibanaRequest, Logger } from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import { isEqual } from 'lodash/fp';
import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
@ -29,7 +29,7 @@ import {
import { EndpointArtifactExceptionValidationError } from './errors';
import { EndpointExceptionsValidationError } from './endpoint_exception_errors';
const NO_GLOBAL_ARTIFACT_AUTHZ_MESSAGE = i18n.translate(
const OWNER_SPACE_ID_TAG_MANAGEMENT_NOT_ALLOWED_MESSAGE = i18n.translate(
'xpack.securitySolution.baseValidator.noGlobalArtifactAuthzApiMessage',
{
defaultMessage:
@ -37,6 +37,23 @@ const NO_GLOBAL_ARTIFACT_AUTHZ_MESSAGE = i18n.translate(
}
);
export const GLOBAL_ARTIFACT_MANAGEMENT_NOT_ALLOWED_MESSAGE = i18n.translate(
'xpack.securitySolution.baseValidator.noGlobalArtifactManagementMessage',
{
defaultMessage:
'Management of global artifacts requires additional privilege (global artifact management)',
}
);
const ITEM_CANNOT_BE_MANAGED_IN_CURRENT_SPACE_MESSAGE = (spaceIds: string[]): string =>
i18n.translate('xpack.securitySolution.baseValidator.cannotManageItemInCurrentSpace', {
defaultMessage: `Updates to this shared item can only be done from the following space {numberOfSpaces, plural, one {ID} other {IDs} }: {itemOwnerSpaces} (or by someone having global artifact management privilege)`,
values: {
numberOfSpaces: spaceIds.length,
itemOwnerSpaces: spaceIds.join(', '),
},
});
export const BasicEndpointExceptionDataSchema = schema.object(
{
// must have a name
@ -66,6 +83,7 @@ export const BasicEndpointExceptionDataSchema = schema.object(
*/
export class BaseValidator {
private readonly endpointAuthzPromise: ReturnType<EndpointAppContextService['getEndpointAuthz']>;
protected readonly logger: Logger;
constructor(
protected readonly endpointAppContext: EndpointAppContextService,
@ -74,6 +92,8 @@ export class BaseValidator {
*/
private readonly request?: KibanaRequest
) {
this.logger = endpointAppContext.createLogger(this.constructor.name ?? 'artifactBaseValidator');
if (this.request) {
this.endpointAuthzPromise = this.endpointAppContext.getEndpointAuthz(this.request);
} else {
@ -104,8 +124,8 @@ export class BaseValidator {
}
}
protected isItemByPolicy(item: ExceptionItemLikeOptions): boolean {
return isArtifactByPolicy(item);
protected isItemByPolicy(item: Partial<Pick<ExceptionListItemSchema, 'tags'>>): boolean {
return isArtifactByPolicy(item as Pick<ExceptionListItemSchema, 'tags'>);
}
protected async isAllowedToCreateArtifactsByPolicy(): Promise<boolean> {
@ -221,7 +241,7 @@ export class BaseValidator {
!(await this.endpointAuthzPromise).canManageGlobalArtifacts
) {
throw new EndpointArtifactExceptionValidationError(
`Endpoint authorization failure. ${NO_GLOBAL_ARTIFACT_AUTHZ_MESSAGE}`,
`${ENDPOINT_AUTHZ_ERROR_MESSAGE}. ${OWNER_SPACE_ID_TAG_MANAGEMENT_NOT_ALLOWED_MESSAGE}`,
403
);
}
@ -245,7 +265,7 @@ export class BaseValidator {
(ownerSpaceIds.length === 1 && ownerSpaceIds[0] !== activeSpaceId)
) {
throw new EndpointArtifactExceptionValidationError(
`Endpoint authorization failure. ${NO_GLOBAL_ARTIFACT_AUTHZ_MESSAGE}`,
`${ENDPOINT_AUTHZ_ERROR_MESSAGE}. ${OWNER_SPACE_ID_TAG_MANAGEMENT_NOT_ALLOWED_MESSAGE}`,
403
);
}
@ -282,4 +302,78 @@ export class BaseValidator {
setArtifactOwnerSpaceId(item, await this.getActiveSpaceId());
}
}
protected async validateCanCreateGlobalArtifacts(item: ExceptionItemLikeOptions): Promise<void> {
if (this.endpointAppContext.experimentalFeatures.endpointManagementSpaceAwarenessEnabled) {
if (
!this.isItemByPolicy(item) &&
!(await this.endpointAuthzPromise).canManageGlobalArtifacts
) {
throw new EndpointArtifactExceptionValidationError(
`${ENDPOINT_AUTHZ_ERROR_MESSAGE}. ${GLOBAL_ARTIFACT_MANAGEMENT_NOT_ALLOWED_MESSAGE}`,
403
);
}
}
}
protected async validateCanUpdateItemInActiveSpace(
updatedItem: Partial<Pick<ExceptionListItemSchema, 'tags'>>,
currentSavedItem: ExceptionListItemSchema
): Promise<void> {
if (this.endpointAppContext.experimentalFeatures.endpointManagementSpaceAwarenessEnabled) {
// Those with global artifact management privilege can do it all
if ((await this.endpointAuthzPromise).canManageGlobalArtifacts) {
return;
}
// If either the updated item or the saved item is a global artifact, then
// error out - user needs global artifact management privilege
if (!this.isItemByPolicy(updatedItem) || !this.isItemByPolicy(currentSavedItem)) {
throw new EndpointArtifactExceptionValidationError(
`${ENDPOINT_AUTHZ_ERROR_MESSAGE}. ${GLOBAL_ARTIFACT_MANAGEMENT_NOT_ALLOWED_MESSAGE}`,
403
);
}
const itemOwnerSpaces = getArtifactOwnerSpaceIds(currentSavedItem);
// Per-space items can only be managed from one of the `ownerSpaceId`'s
if (!itemOwnerSpaces.includes(await this.getActiveSpaceId())) {
throw new EndpointArtifactExceptionValidationError(
ITEM_CANNOT_BE_MANAGED_IN_CURRENT_SPACE_MESSAGE(itemOwnerSpaces),
403
);
}
}
}
protected async validateCanDeleteItemInActiveSpace(
currentSavedItem: ExceptionListItemSchema
): Promise<void> {
if (this.endpointAppContext.experimentalFeatures.endpointManagementSpaceAwarenessEnabled) {
// Those with global artifact management privilege can do it all
if ((await this.endpointAuthzPromise).canManageGlobalArtifacts) {
return;
}
// If item is a global artifact then error - user must have global artifact management privilege
if (!this.isItemByPolicy(currentSavedItem)) {
throw new EndpointArtifactExceptionValidationError(
`${ENDPOINT_AUTHZ_ERROR_MESSAGE}. ${GLOBAL_ARTIFACT_MANAGEMENT_NOT_ALLOWED_MESSAGE}`,
403
);
}
const itemOwnerSpaces = getArtifactOwnerSpaceIds(currentSavedItem);
// Per-space items can only be deleted from one of the `ownerSpaceId`'s
if (!itemOwnerSpaces.includes(await this.getActiveSpaceId())) {
throw new EndpointArtifactExceptionValidationError(
ITEM_CANNOT_BE_MANAGED_IN_CURRENT_SPACE_MESSAGE(itemOwnerSpaces),
403
);
}
}
}
}

View file

@ -250,8 +250,9 @@ export class BlocklistValidator extends BaseValidator {
return item;
}
async validatePreDeleteItem(): Promise<void> {
async validatePreDeleteItem(currentItem: ExceptionListItemSchema): Promise<void> {
await this.validateHasWritePrivilege();
await this.validateCanDeleteItemInActiveSpace(currentItem);
}
async validatePreGetOneItem(): Promise<void> {
@ -301,6 +302,7 @@ export class BlocklistValidator extends BaseValidator {
await this.validateByPolicyItem(updatedItem);
await this.validateUpdateOwnerSpaceIds(updatedItem, currentItem);
await this.validateCanUpdateItemInActiveSpace(_updatedItem, currentItem);
if (!hasArtifactOwnerSpaceId(_updatedItem)) {
await this.setOwnerSpaceId(_updatedItem);

View file

@ -0,0 +1,29 @@
/*
* 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 { BlocklistValidator } from './blocklist_validator';
import { httpServerMock } from '@kbn/core-http-server-mocks';
import { createMockEndpointAppContextService } from '../../../endpoint/mocks';
describe('Blocklists API validations', () => {
it('should initialize', () => {
expect(
new BlocklistValidator(
createMockEndpointAppContextService(),
httpServerMock.createKibanaRequest()
)
).not.toBeUndefined();
});
// -----------------------------------------------------------------------------
//
// API TESTS FOR THIS ARTIFACT TYPE SHOULD BE COVERED WITH INTEGRATION TESTS.
// ADD THEM HERE:
//
// `x-pack/test/security_solution_api_integration/test_suites/edr_workflows`
//
// -----------------------------------------------------------------------------
});

View file

@ -0,0 +1,29 @@
/*
* 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 { httpServerMock } from '@kbn/core-http-server-mocks';
import { createMockEndpointAppContextService } from '../../../endpoint/mocks';
import { EndpointExceptionsValidator } from './endpoint_exceptions_validator';
describe('Endpoint Exceptions API validations', () => {
it('should initialize', () => {
expect(
new EndpointExceptionsValidator(
createMockEndpointAppContextService(),
httpServerMock.createKibanaRequest()
)
).not.toBeUndefined();
});
// -----------------------------------------------------------------------------
//
// API TESTS FOR THIS ARTIFACT TYPE SHOULD BE COVERED WITH INTEGRATION TESTS.
// ADD THEM HERE:
//
// `x-pack/test/security_solution_api_integration/test_suites/edr_workflows`
//
// -----------------------------------------------------------------------------
});

View file

@ -11,8 +11,9 @@ import type {
} from '@kbn/lists-plugin/server';
import { ENDPOINT_LIST_ID } from '@kbn/securitysolution-list-constants';
import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
import { EndpointExceptionsValidationError } from './endpoint_exception_errors';
import { hasArtifactOwnerSpaceId } from '../../../../common/endpoint/service/artifacts/utils';
import { BaseValidator } from './base_validator';
import { BaseValidator, GLOBAL_ARTIFACT_MANAGEMENT_NOT_ALLOWED_MESSAGE } from './base_validator';
export class EndpointExceptionsValidator extends BaseValidator {
static isEndpointException(item: { listId: string }): boolean {
@ -24,7 +25,21 @@ export class EndpointExceptionsValidator extends BaseValidator {
}
protected async validateHasWritePrivilege(): Promise<void> {
return this.validateHasEndpointExceptionsPrivileges('canWriteEndpointExceptions');
await this.validateHasEndpointExceptionsPrivileges('canWriteEndpointExceptions');
if (this.endpointAppContext.experimentalFeatures.endpointManagementSpaceAwarenessEnabled) {
// Endpoint Exceptions are currently ONLY global, so we need to make sure the user
// also has the new Global Artifacts privilege
try {
await this.validateHasPrivilege('canManageGlobalArtifacts');
} catch (error) {
// We provide a more detailed error here
throw new EndpointExceptionsValidationError(
`${error.message}. ${GLOBAL_ARTIFACT_MANAGEMENT_NOT_ALLOWED_MESSAGE}`,
403
);
}
}
}
async validatePreCreateItem(item: CreateExceptionListItemOptions) {
@ -50,8 +65,9 @@ export class EndpointExceptionsValidator extends BaseValidator {
return item;
}
async validatePreDeleteItem(): Promise<void> {
async validatePreDeleteItem(currentItem: ExceptionListItemSchema): Promise<void> {
await this.validateHasWritePrivilege();
await this.validateCanDeleteItemInActiveSpace(currentItem);
}
async validatePreGetOneItem(): Promise<void> {

View file

@ -0,0 +1,29 @@
/*
* 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 { httpServerMock } from '@kbn/core-http-server-mocks';
import { createMockEndpointAppContextService } from '../../../endpoint/mocks';
import { EventFilterValidator } from './event_filter_validator';
describe('Endpoint Exceptions API validations', () => {
it('should initialize', () => {
expect(
new EventFilterValidator(
createMockEndpointAppContextService(),
httpServerMock.createKibanaRequest()
)
).not.toBeUndefined();
});
// -----------------------------------------------------------------------------
//
// API TESTS FOR THIS ARTIFACT TYPE SHOULD BE COVERED WITH INTEGRATION TESTS.
// ADD THEM HERE:
//
// `x-pack/test/security_solution_api_integration/test_suites/edr_workflows`
//
// -----------------------------------------------------------------------------
});

View file

@ -89,6 +89,7 @@ export class EventFilterValidator extends BaseValidator {
await this.validateByPolicyItem(updatedItem);
await this.validateUpdateOwnerSpaceIds(_updatedItem, currentItem);
await this.validateCanUpdateItemInActiveSpace(_updatedItem, currentItem);
if (!hasArtifactOwnerSpaceId(_updatedItem)) {
await this.setOwnerSpaceId(_updatedItem);
@ -115,8 +116,9 @@ export class EventFilterValidator extends BaseValidator {
await this.validateHasReadPrivilege();
}
async validatePreDeleteItem(): Promise<void> {
async validatePreDeleteItem(currentItem: ExceptionListItemSchema): Promise<void> {
await this.validateHasWritePrivilege();
await this.validateCanDeleteItemInActiveSpace(currentItem);
}
async validatePreExport(): Promise<void> {

View file

@ -0,0 +1,29 @@
/*
* 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 { httpServerMock } from '@kbn/core-http-server-mocks';
import { createMockEndpointAppContextService } from '../../../endpoint/mocks';
import { HostIsolationExceptionsValidator } from './host_isolation_exceptions_validator';
describe('Endpoint Exceptions API validations', () => {
it('should initialize', () => {
expect(
new HostIsolationExceptionsValidator(
createMockEndpointAppContextService(),
httpServerMock.createKibanaRequest()
)
).not.toBeUndefined();
});
// -----------------------------------------------------------------------------
//
// API TESTS FOR THIS ARTIFACT TYPE SHOULD BE COVERED WITH INTEGRATION TESTS.
// ADD THEM HERE:
//
// `x-pack/test/security_solution_api_integration/test_suites/edr_workflows`
//
// -----------------------------------------------------------------------------
});

View file

@ -97,6 +97,7 @@ export class HostIsolationExceptionsValidator extends BaseValidator {
await this.validateHostIsolationData(updatedItem);
await this.validateByPolicyItem(updatedItem);
await this.validateUpdateOwnerSpaceIds(_updatedItem, currentItem);
await this.validateCanUpdateItemInActiveSpace(_updatedItem, currentItem);
if (!hasArtifactOwnerSpaceId(_updatedItem)) {
await this.setOwnerSpaceId(_updatedItem);
@ -113,8 +114,9 @@ export class HostIsolationExceptionsValidator extends BaseValidator {
await this.validateHasReadPrivilege();
}
async validatePreDeleteItem(): Promise<void> {
async validatePreDeleteItem(currentItem: ExceptionListItemSchema): Promise<void> {
await this.validateHasDeletePrivilege();
await this.validateCanDeleteItemInActiveSpace(currentItem);
}
async validatePreExport(): Promise<void> {

View file

@ -7,9 +7,14 @@
import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
import { listMock } from '@kbn/lists-plugin/server/mocks';
import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
import { buildSpaceOwnerIdTag } from '../../../../common/endpoint/service/artifacts/utils';
import { BaseValidator } from './base_validator';
import type { ExceptionItemLikeOptions } from '../types';
import { BY_POLICY_ARTIFACT_TAG_PREFIX } from '../../../../common/endpoint/service/artifacts';
import {
BY_POLICY_ARTIFACT_TAG_PREFIX,
GLOBAL_ARTIFACT_TAG,
} from '../../../../common/endpoint/service/artifacts';
/**
* Exposes all `protected` methods of `BaseValidator` by prefixing them with an underscore.
@ -56,6 +61,21 @@ export class BaseValidatorMock extends BaseValidator {
): Promise<void> {
return this.validateUpdateOwnerSpaceIds(updatedItem, currentItem);
}
_validateCanCreateGlobalArtifacts(item: ExceptionItemLikeOptions): Promise<void> {
return this.validateCanCreateGlobalArtifacts(item);
}
_validateCanUpdateItemInActiveSpace(
updatedItem: Partial<Pick<ExceptionListItemSchema, 'tags'>>,
currentSavedItem: ExceptionListItemSchema
): Promise<void> {
return this.validateCanUpdateItemInActiveSpace(updatedItem, currentSavedItem);
}
_validateCanDeleteItemInActiveSpace(currentSavedItem: ExceptionListItemSchema): Promise<void> {
return this.validateCanDeleteItemInActiveSpace(currentSavedItem);
}
}
export const createExceptionItemLikeOptionsMock = (
@ -69,3 +89,14 @@ export const createExceptionItemLikeOptionsMock = (
...overrides,
};
};
export const createExceptionListItemMock = (
overrides: Partial<ExceptionListItemSchema> = {}
): ExceptionListItemSchema => {
return listMock.getExceptionListItemSchemaMock({
namespace_type: 'agnostic',
os_types: ['windows'],
tags: [GLOBAL_ARTIFACT_TAG, buildSpaceOwnerIdTag(DEFAULT_SPACE_ID)],
...overrides,
});
};

View file

@ -0,0 +1,29 @@
/*
* 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 { httpServerMock } from '@kbn/core-http-server-mocks';
import { createMockEndpointAppContextService } from '../../../endpoint/mocks';
import { TrustedAppValidator } from './trusted_app_validator';
describe('Endpoint Exceptions API validations', () => {
it('should initialize', () => {
expect(
new TrustedAppValidator(
createMockEndpointAppContextService(),
httpServerMock.createKibanaRequest()
)
).not.toBeUndefined();
});
// -----------------------------------------------------------------------------
//
// API TESTS FOR THIS ARTIFACT TYPE SHOULD BE COVERED WITH INTEGRATION TESTS.
// ADD THEM HERE:
//
// `x-pack/test/security_solution_api_integration/test_suites/edr_workflows`
//
// -----------------------------------------------------------------------------
});

View file

@ -208,14 +208,16 @@ export class TrustedAppValidator extends BaseValidator {
await this.validateCanCreateByPolicyArtifacts(item);
await this.validateByPolicyItem(item);
await this.validateCreateOwnerSpaceIds(item);
await this.validateCanCreateGlobalArtifacts(item);
await this.setOwnerSpaceId(item);
return item;
}
async validatePreDeleteItem(): Promise<void> {
async validatePreDeleteItem(currentItem: ExceptionListItemSchema): Promise<void> {
await this.validateHasWritePrivilege();
await this.validateCanDeleteItemInActiveSpace(currentItem);
}
async validatePreGetOneItem(): Promise<void> {
@ -260,6 +262,7 @@ export class TrustedAppValidator extends BaseValidator {
await this.validateByPolicyItem(updatedItem);
await this.validateUpdateOwnerSpaceIds(_updatedItem, currentItem);
await this.validateCanUpdateItemInActiveSpace(_updatedItem, currentItem);
if (!hasArtifactOwnerSpaceId(_updatedItem)) {
await this.setOwnerSpaceId(_updatedItem);

View file

@ -212,8 +212,7 @@ export default function ({ getService }: FtrProviderContext) {
.expect(403);
});
// TODO:PT Un-skip in next PR. I got a little ahead of myself and added a test for the change that wil come with the next PR.
it.skip('should error if attempting to update a global artifact', async () => {
it('should error if attempting to update a global artifact', async () => {
await supertestArtifactManager
.put(addSpaceIdToPath('/', spaceOneId, EXCEPTION_LIST_ITEM_URL))
.set('elastic-api-version', '2023-10-31')
@ -229,8 +228,7 @@ export default function ({ getService }: FtrProviderContext) {
.expect(403);
});
// TODO:PT Un-skip in next PR. I got a little ahead of myself and added a test for the change that wil come with the next PR.
it.skip('should error when attempting to change a global artifact to per-policy', async () => {
it('should error when attempting to change a global artifact to per-policy', async () => {
await supertestArtifactManager
.put(addSpaceIdToPath('/', spaceOneId, EXCEPTION_LIST_ITEM_URL))
.set('elastic-api-version', '2023-10-31')
@ -247,6 +245,45 @@ export default function ({ getService }: FtrProviderContext) {
)
.expect(403);
});
it('should error when attempting to update item outside of its owner space id', async () => {
const { body } = await supertestArtifactManager
.put(addSpaceIdToPath('/', spaceTwoId, EXCEPTION_LIST_ITEM_URL))
.set('elastic-api-version', '2023-10-31')
.set('x-elastic-internal-origin', 'kibana')
.set('kbn-xsrf', 'true')
.on('error', createSupertestErrorLogger(log).ignoreCodes([403]))
.send(
exceptionItemToCreateExceptionItem({
...spaceOnePerPolicyArtifact.artifact,
description: 'updating item',
})
)
.expect(403);
expect(body.message).to.eql(
`EndpointArtifactError: Updates to this shared item can only be done from the following space ID: ${spaceOneId} (or by someone having global artifact management privilege)`
);
});
it('should error when attempting to delete item outside of its owner space id', async () => {
const { body } = await supertestArtifactManager
.delete(addSpaceIdToPath('/', spaceTwoId, EXCEPTION_LIST_ITEM_URL))
.set('elastic-api-version', '2023-10-31')
.set('x-elastic-internal-origin', 'kibana')
.set('kbn-xsrf', 'true')
.on('error', createSupertestErrorLogger(log).ignoreCodes([403]))
.query({
item_id: spaceOnePerPolicyArtifact.artifact.item_id,
namespace_type: spaceOnePerPolicyArtifact.artifact.namespace_type,
})
.send()
.expect(403);
expect(body.message).to.eql(
`EndpointArtifactError: Updates to this shared item can only be done from the following space ID: ${spaceOneId} (or by someone having global artifact management privilege)`
);
});
});
describe('and user has privilege to manage global artifacts', () => {
@ -332,6 +369,40 @@ export default function ({ getService }: FtrProviderContext) {
)
.expect(200);
});
it('should allow updating items outside of their owner space ids', async () => {
await supertestGlobalArtifactManager
.put(addSpaceIdToPath('/', spaceTwoId, EXCEPTION_LIST_ITEM_URL))
.set('elastic-api-version', '2023-10-31')
.set('x-elastic-internal-origin', 'kibana')
.set('kbn-xsrf', 'true')
.on('error', createSupertestErrorLogger(log))
.send(
exceptionItemToCreateExceptionItem({
...spaceOneGlobalArtifact.artifact,
description: 'updated from outside of its own space id',
})
)
.expect(200);
});
it('should allow deleting items outside of their owner space ids', async () => {
await supertestGlobalArtifactManager
.delete(addSpaceIdToPath('/', spaceTwoId, EXCEPTION_LIST_ITEM_URL))
.set('elastic-api-version', '2023-10-31')
.set('x-elastic-internal-origin', 'kibana')
.set('kbn-xsrf', 'true')
.on('error', createSupertestErrorLogger(log).ignoreCodes([403]))
.query({
item_id: spaceOnePerPolicyArtifact.artifact.item_id,
namespace_type: spaceOnePerPolicyArtifact.artifact.namespace_type,
})
.send()
.expect(200);
// @ts-expect-error
spaceOnePerPolicyArtifact = undefined;
});
});
});
}