Skip to content

Commit

Permalink
[Security Solution][Endpoint] Add RBAC to Endpoint Policy List and Po…
Browse files Browse the repository at this point in the history
…licy Details pages (#146480)

## Summary

- The following changes were done in support of RBAC for the policy
management from security solution:
    - Pages are only accessible if user has `read` permissions
- If user does not have `read` or `write` permissions, the link to the
Policy list is remove from the Security Solution management page
- If user ONLY has `read`, then the Policy Details save button is
removed and all form controls (ex. switches, checkboxes, etc) are
disabled
- If user does not have `read` permissions to the Endpoint list page,
then the policy list `Endpoints` column is displayed as plain text (no
link)
- Fixes a bug with the `Cancel` button on the Policy Details, which was
redirecting the user by default to the Endpoint List - correct behavior
is to redirect to the policy list by default
  • Loading branch information
paul-tavares authored Dec 1, 2022
1 parent c3d1d9e commit d3b4d39
Show file tree
Hide file tree
Showing 15 changed files with 295 additions and 154 deletions.
273 changes: 158 additions & 115 deletions x-pack/plugins/security_solution/public/management/links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,20 @@ import { SecurityPageName } from '../app/types';

import { calculateEndpointAuthz } from '../../common/endpoint/service/authz';
import type { StartPlugins } from '../types';
import { links, getManagementFilteredLinks } from './links';
import { getManagementFilteredLinks, links } from './links';
import { allowedExperimentalValues } from '../../common/experimental_features';
import { ExperimentalFeaturesService } from '../common/experimental_features_service';
import { getEndpointAuthzInitialStateMock } from '../../common/endpoint/service/authz/mocks';
import { licenseService as _licenseService } from '../common/hooks/use_license';
import type { LicenseService } from '../../common/license';
import { createLicenseServiceMock } from '../../common/license/mocks';
import type { FleetAuthz } from '@kbn/fleet-plugin/common';
import { createFleetAuthzMock } from '@kbn/fleet-plugin/common';
import type { DeepPartial } from '@kbn/utility-types';
import { merge } from 'lodash';
import { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants';

jest.mock('../common/hooks/use_license');

jest.mock('../../common/endpoint/service/authz', () => {
const originalModule = jest.requireActual('../../common/endpoint/service/authz');
Expand All @@ -27,16 +37,33 @@ jest.mock('../../common/endpoint/service/authz', () => {

jest.mock('../common/lib/kibana');

const licenseServiceMock = _licenseService as jest.Mocked<LicenseService>;

describe('links', () => {
let coreMockStarted: ReturnType<typeof coreMock.createStart>;
let getPlugins: (roles: string[]) => StartPlugins;
let fakeHttpServices: jest.Mocked<HttpSetup>;

const getLinksWithout = (...excludedLinks: SecurityPageName[]) => ({
...links,
links: links.links?.filter((link) => !excludedLinks.includes(link.id)),
});

const getPlugins = (
roles: string[],
fleetAuthzOverrides: DeepPartial<FleetAuthz> = {}
): StartPlugins => {
return {
security: {
authc: {
getCurrentUser: jest.fn().mockReturnValue({ roles }),
},
},
fleet: {
authz: merge(createFleetAuthzMock(), fleetAuthzOverrides),
},
} as unknown as StartPlugins;
};

beforeAll(() => {
ExperimentalFeaturesService.init({
experimentalFeatures: { ...allowedExperimentalValues },
Expand All @@ -46,22 +73,11 @@ describe('links', () => {
beforeEach(() => {
coreMockStarted = coreMock.createStart();
fakeHttpServices = coreMockStarted.http as jest.Mocked<HttpSetup>;
});

afterEach(() => {
fakeHttpServices.get.mockClear();
getPlugins = (roles) =>
({
security: {
authc: {
getCurrentUser: jest.fn().mockReturnValue({ roles }),
},
},
fleet: {
authz: {
fleet: {
all: true,
},
},
},
} as unknown as StartPlugins);
Object.assign(licenseServiceMock, createLicenseServiceMock());
});

it('should return all links for user with all sub-feature privileges', async () => {
Expand All @@ -88,146 +104,173 @@ describe('links', () => {
});
});

// todo: these tests should be updated, because in the end, showing/hiding HIE depends on nothing
// else but the mock return of `calculateEndpointAuthz`.
// These tests should check what is the value of `hasHostIsolationExceptions` which is passed to
// `calculateEndpointAuthz`.
describe('Host Isolation Exception', () => {
it('should return all but HIE when NO isolation permission due to privilege', async () => {
(calculateEndpointAuthz as jest.Mock).mockReturnValue({
canIsolateHost: false,
canUnIsolateHost: false,
canAccessEndpointManagement: true,
canReadActionsLogManagement: true,
canReadEndpointList: true,
canReadTrustedApplications: true,
canReadEventFilters: true,
});
it('should NOT return HIE if `canReadHostIsolationExceptions` is false', async () => {
(calculateEndpointAuthz as jest.Mock).mockReturnValue(
getEndpointAuthzInitialStateMock({ canReadHostIsolationExceptions: false })
);

const filteredLinks = await getManagementFilteredLinks(
coreMockStarted,
getPlugins(['superuser'])
);

expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.hostIsolationExceptions));
});

it('should return all but HIE when NO isolation permission due to license and NO host isolation exceptions entry', async () => {
(calculateEndpointAuthz as jest.Mock).mockReturnValue({
canIsolateHost: false,
canUnIsolateHost: true,
canAccessEndpointManagement: true,
canReadActionsLogManagement: true,
canReadEndpointList: true,
canReadTrustedApplications: true,
canReadEventFilters: true,
});
it('should NOT return HIE if license is lower than Enterprise and NO HIE entries exist', async () => {
(calculateEndpointAuthz as jest.Mock).mockReturnValue(
getEndpointAuthzInitialStateMock({ canReadHostIsolationExceptions: false })
);

fakeHttpServices.get.mockResolvedValue({ total: 0 });
licenseServiceMock.isPlatinumPlus.mockReturnValue(false);
ExperimentalFeaturesService.init({
experimentalFeatures: { ...allowedExperimentalValues, endpointRbacEnabled: true },
});

const filteredLinks = await getManagementFilteredLinks(
coreMockStarted,
getPlugins(['superuser'])
getPlugins([], {
packagePrivileges: {
endpoint: {
actions: {
readHostIsolationExceptions: {
executePackageAction: true,
},
},
},
},
})
);
expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.hostIsolationExceptions));
});

it('should return all but HIE when HAS isolation permission AND has HIE entry but not superuser', async () => {
(calculateEndpointAuthz as jest.Mock).mockReturnValue({
canIsolateHost: false,
canUnIsolateHost: true,
canAccessEndpointManagement: false,
canReadActionsLogManagement: true,
canReadEndpointList: true,
canReadTrustedApplications: true,
canReadEventFilters: true,
expect(fakeHttpServices.get).toHaveBeenCalledWith('/api/exception_lists/items/_find', {
query: expect.objectContaining({
list_id: [ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id],
}),
});
fakeHttpServices.get.mockResolvedValue({ total: 1 });

const filteredLinks = await getManagementFilteredLinks(
coreMockStarted,
getPlugins(['superuser'])
expect(calculateEndpointAuthz as jest.Mock).toHaveBeenLastCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
false
);
expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.hostIsolationExceptions));
});

it('should return all when NO isolation permission due to license but HAS at least one host isolation exceptions entry', async () => {
it('should return HIE if license is lower than Enterprise, but HIE entries exist', async () => {
(calculateEndpointAuthz as jest.Mock).mockReturnValue(
getEndpointAuthzInitialStateMock({
canIsolateHost: false,
})
getEndpointAuthzInitialStateMock({ canReadHostIsolationExceptions: true })
);
fakeHttpServices.get.mockResolvedValue({ total: 1 });

fakeHttpServices.get.mockResolvedValue({ total: 100 });
licenseServiceMock.isPlatinumPlus.mockReturnValue(false);
ExperimentalFeaturesService.init({
experimentalFeatures: { ...allowedExperimentalValues, endpointRbacEnabled: true },
});

const filteredLinks = await getManagementFilteredLinks(
coreMockStarted,
getPlugins(['superuser'])
getPlugins([], {
packagePrivileges: {
endpoint: {
actions: {
readHostIsolationExceptions: {
executePackageAction: true,
},
},
},
},
})
);
expect(filteredLinks).toEqual(links);

expect(fakeHttpServices.get).toHaveBeenCalledWith('/api/exception_lists/items/_find', {
query: expect.objectContaining({
list_id: [ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id],
}),
});
expect(calculateEndpointAuthz as jest.Mock).toHaveBeenLastCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
true
);
expect(filteredLinks).toEqual(getLinksWithout());
});
});

it('should not affect showing Action Log if getting from HIE API throws error', async () => {
(calculateEndpointAuthz as jest.Mock).mockReturnValue({
canIsolateHost: false,
canUnIsolateHost: true,
canReadActionsLogManagement: true,
canReadEndpointList: true,
canReadTrustedApplications: true,
canReadEventFilters: true,
// this can be removed after removing endpointRbacEnabled feature flag
describe('without endpointRbacEnabled', () => {
beforeAll(() => {
ExperimentalFeaturesService.init({
experimentalFeatures: { ...allowedExperimentalValues, endpointRbacEnabled: false },
});
fakeHttpServices.get.mockRejectedValue(new Error());
});

const filteredLinks = await getManagementFilteredLinks(
coreMockStarted,
getPlugins(['superuser'])
);
expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.hostIsolationExceptions));
it('shows Trusted Applications for non-superuser, too', async () => {
(calculateEndpointAuthz as jest.Mock).mockReturnValue(getEndpointAuthzInitialStateMock());

const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins([]));

expect(filteredLinks).toEqual(links);
});
});

it('should not affect hiding Action Log if getting from HIE API throws error', async () => {
(calculateEndpointAuthz as jest.Mock).mockReturnValue({
canIsolateHost: false,
canUnIsolateHost: true,
canReadActionsLogManagement: false,
canReadEndpointList: true,
canReadTrustedApplications: true,
canReadEventFilters: true,
// this can be the default after removing endpointRbacEnabled feature flag
describe('with endpointRbacEnabled', () => {
beforeAll(() => {
ExperimentalFeaturesService.init({
experimentalFeatures: { ...allowedExperimentalValues, endpointRbacEnabled: true },
});
fakeHttpServices.get.mockRejectedValue(new Error());
});

const filteredLinks = await getManagementFilteredLinks(
coreMockStarted,
getPlugins(['superuser'])
);
expect(filteredLinks).toEqual(
getLinksWithout(
SecurityPageName.hostIsolationExceptions,
SecurityPageName.responseActionsHistory
)
it('should hide Trusted Applications for user without privilege', async () => {
(calculateEndpointAuthz as jest.Mock).mockReturnValue(
getEndpointAuthzInitialStateMock({
canReadTrustedApplications: false,
})
);

const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins([]));

expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.trustedApps));
});
});

it('should hide Trusted Applications for user without privilege', async () => {
(calculateEndpointAuthz as jest.Mock).mockReturnValue(
getEndpointAuthzInitialStateMock({
canReadTrustedApplications: false,
})
);
it('should show Trusted Applications for user with privilege', async () => {
(calculateEndpointAuthz as jest.Mock).mockReturnValue(getEndpointAuthzInitialStateMock());

const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins([]));
const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins([]));

expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.trustedApps));
});
expect(filteredLinks).toEqual(links);
});

it('should hide Event Filters for user without privilege', async () => {
(calculateEndpointAuthz as jest.Mock).mockReturnValue(
getEndpointAuthzInitialStateMock({
canReadEventFilters: false,
})
);

it('should hide Event Filters for user without privilege', async () => {
(calculateEndpointAuthz as jest.Mock).mockReturnValue(
getEndpointAuthzInitialStateMock({
canReadEventFilters: false,
})
);
const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins([]));

const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins([]));
expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.eventFilters));
});

it('should NOT return policies if `canReadPolicyManagement` is `false`', async () => {
(calculateEndpointAuthz as jest.Mock).mockReturnValue(
getEndpointAuthzInitialStateMock({
canReadPolicyManagement: false,
})
);

expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.eventFilters));
const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins([]));

expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.policies));
});
});

describe('Endpoint List', () => {
Expand Down
8 changes: 6 additions & 2 deletions x-pack/plugins/security_solution/public/management/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ import { IconSiemRules } from './icons/siem_rules';
import { IconTrustedApplications } from './icons/trusted_applications';
import { HostIsolationExceptionsApiClient } from './pages/host_isolation_exceptions/host_isolation_exceptions_api_client';
import { ExperimentalFeaturesService } from '../common/experimental_features_service';
import { KibanaServices } from '../common/lib/kibana';

const categories = [
{
Expand Down Expand Up @@ -269,7 +268,7 @@ export const getManagementFilteredLinks = async (
)
) {
hasHostIsolationExceptions = await checkArtifactHasData(
HostIsolationExceptionsApiClient.getInstance(KibanaServices.get().http)
HostIsolationExceptionsApiClient.getInstance(core.http)
);
}

Expand All @@ -279,6 +278,7 @@ export const getManagementFilteredLinks = async (
canReadEndpointList,
canReadTrustedApplications,
canReadEventFilters,
canReadPolicyManagement,
} = fleetAuthz
? calculateEndpointAuthz(
licenseService,
Expand All @@ -294,6 +294,10 @@ export const getManagementFilteredLinks = async (
linksToExclude.push(SecurityPageName.endpoints);
}

if (!canReadPolicyManagement) {
linksToExclude.push(SecurityPageName.policies);
}

if (!canReadActionsLogManagement) {
linksToExclude.push(SecurityPageName.responseActionsHistory);
}
Expand Down
Loading

0 comments on commit d3b4d39

Please sign in to comment.