Skip to content

Commit

Permalink
[Security Solution] UI trusted applications RBAC (#145593)
Browse files Browse the repository at this point in the history
## Summary

RBAC UI features for Trusted Applications. To test, enable
`endpointRbacEnabled` feature-flag, create a non-superuser user with
_Security: ALL_ privilege and (All | Read | None) sub-privilege for
_Trusted Applications_.
<img width="541" alt="image"
src="https://user-images.githubusercontent.com/39014407/203073992-fb71e293-2cd8-4639-8d61-4867e39ef071.png">

The modification should:
- hide Trusted Apps from Manage navigation items if privilege is NONE,
(note: it is still displayed for non-superusers, if the feature flag is
disabled)
- disable add/edit/delete for Trusted Applications if privilege is READ.

## ⚠️  Note
This PR focuses on _Read_ and _None_. The sub-privilege _All_ does not
work perfectly at the moment, because of unauthorised API calls. A
follow-up PR will fix this, after this PR is merged:
#145361

### Checklist

Delete any items that are not applicable to this PR.
- [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
  • Loading branch information
gergoabraham authored Nov 23, 2022
1 parent 9934048 commit 4c6a915
Show file tree
Hide file tree
Showing 4 changed files with 152 additions and 34 deletions.
84 changes: 61 additions & 23 deletions x-pack/plugins/security_solution/public/management/links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ describe('links', () => {
let getPlugins: (roles: string[]) => StartPlugins;
let fakeHttpServices: jest.Mocked<HttpSetup>;

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

beforeAll(() => {
ExperimentalFeaturesService.init({
experimentalFeatures: { ...allowedExperimentalValues },
Expand Down Expand Up @@ -103,10 +108,7 @@ describe('links', () => {
coreMockStarted,
getPlugins(['superuser'])
);
expect(filteredLinks).toEqual({
...links,
links: links.links?.filter((link) => link.id !== SecurityPageName.hostIsolationExceptions),
});
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 () => {
Expand All @@ -123,10 +125,7 @@ describe('links', () => {
coreMockStarted,
getPlugins(['superuser'])
);
expect(filteredLinks).toEqual({
...links,
links: links.links?.filter((link) => link.id !== SecurityPageName.hostIsolationExceptions),
});
expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.hostIsolationExceptions));
});

it('should return all but HIE when HAS isolation permission AND has HIE entry but not superuser', async () => {
Expand All @@ -143,10 +142,7 @@ describe('links', () => {
coreMockStarted,
getPlugins(['superuser'])
);
expect(filteredLinks).toEqual({
...links,
links: links.links?.filter((link) => link.id !== SecurityPageName.hostIsolationExceptions),
});
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 () => {
Expand Down Expand Up @@ -177,10 +173,7 @@ describe('links', () => {
coreMockStarted,
getPlugins(['superuser'])
);
expect(filteredLinks).toEqual({
...links,
links: links.links?.filter((link) => link.id !== SecurityPageName.hostIsolationExceptions),
});
expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.hostIsolationExceptions));
});

it('should not affect hiding Action Log if getting from HIE API throws error', async () => {
Expand All @@ -196,15 +189,60 @@ describe('links', () => {
coreMockStarted,
getPlugins(['superuser'])
);
expect(filteredLinks).toEqual({
...links,
links: links.links?.filter(
(link) =>
link.id !== SecurityPageName.hostIsolationExceptions &&
link.id !== SecurityPageName.responseActionsHistory
),
expect(filteredLinks).toEqual(
getLinksWithout(
SecurityPageName.hostIsolationExceptions,
SecurityPageName.responseActionsHistory
)
);
});
});

// this can be removed after removing endpointRbacEnabled feature flag
describe('without endpointRbacEnabled', () => {
beforeAll(() => {
ExperimentalFeaturesService.init({
experimentalFeatures: { ...allowedExperimentalValues, endpointRbacEnabled: false },
});
});

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);
});
});

// this can be the default after removing endpointRbacEnabled feature flag
describe('with endpointRbacEnabled', () => {
beforeAll(() => {
ExperimentalFeaturesService.init({
experimentalFeatures: { ...allowedExperimentalValues, endpointRbacEnabled: true },
});
});

it('hides Trusted Applications for user without privilege', async () => {
(calculateEndpointAuthz as jest.Mock).mockReturnValue(
getEndpointAuthzInitialStateMock({
canReadTrustedApplications: false,
canReadHostIsolationExceptions: true,
})
);

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

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

it('shows Trusted Applications for user with privilege', async () => {
(calculateEndpointAuthz as jest.Mock).mockReturnValue(getEndpointAuthzInitialStateMock());

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

expect(filteredLinks).toEqual(links);
});
});
describe('Endpoint List', () => {
it('should return all but endpoints link when no Endpoint List READ access', async () => {
Expand Down
30 changes: 19 additions & 11 deletions x-pack/plugins/security_solution/public/management/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,17 +273,21 @@ export const getManagementFilteredLinks = async (
);
}

const { canReadActionsLogManagement, canReadHostIsolationExceptions, canReadEndpointList } =
fleetAuthz
? calculateEndpointAuthz(
licenseService,
fleetAuthz,
currentUser.roles,
isEndpointRbacEnabled,
endpointPermissions,
hasHostIsolationExceptions
)
: getEndpointAuthzInitialState();
const {
canReadActionsLogManagement,
canReadHostIsolationExceptions,
canReadEndpointList,
canReadTrustedApplications,
} = fleetAuthz
? calculateEndpointAuthz(
licenseService,
fleetAuthz,
currentUser.roles,
isEndpointRbacEnabled,
endpointPermissions,
hasHostIsolationExceptions
)
: getEndpointAuthzInitialState();

if (!canReadEndpointList) {
linksToExclude.push(SecurityPageName.endpoints);
Expand All @@ -297,5 +301,9 @@ export const getManagementFilteredLinks = async (
linksToExclude.push(SecurityPageName.hostIsolationExceptions);
}

if (endpointRbacEnabled && !canReadTrustedApplications) {
linksToExclude.push(SecurityPageName.trustedApps);
}

return excludeLinks(linksToExclude);
};
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,19 @@ import { TrustedAppsList } from './trusted_apps_list';
import { exceptionsListAllHttpMocks } from '../../../mocks/exceptions_list_http_mocks';
import { SEARCHABLE_FIELDS } from '../constants';
import { parseQueryFilterToKQL } from '../../../common/utils';
import { useUserPrivileges } from '../../../../common/components/user_privileges';
import type { EndpointPrivileges } from '../../../../../common/endpoint/types';

jest.mock('../../../../common/components/user_privileges');
const mockUserPrivileges = useUserPrivileges as jest.Mock;

describe('When on the trusted applications page', () => {
let render: () => ReturnType<AppContextTestRender['render']>;
let renderResult: ReturnType<typeof render>;
let history: AppContextTestRender['history'];
let mockedContext: AppContextTestRender;
let apiMocks: ReturnType<typeof exceptionsListAllHttpMocks>;
let mockedEndpointPrivileges: Partial<EndpointPrivileges>;

beforeEach(() => {
mockedContext = createAppRootMockRenderer();
Expand All @@ -35,6 +39,13 @@ describe('When on the trusted applications page', () => {
act(() => {
history.push(TRUSTED_APPS_PATH);
});

mockedEndpointPrivileges = { canWriteTrustedApplications: true };
mockUserPrivileges.mockReturnValue({ endpointPrivileges: mockedEndpointPrivileges });
});

afterEach(() => {
mockUserPrivileges.mockReset();
});

it('should search using expected exception item fields', async () => {
Expand All @@ -59,4 +70,60 @@ describe('When on the trusted applications page', () => {
})
);
});

describe('RBAC Trusted Applications', () => {
describe('ALL privilege', () => {
beforeEach(() => {
mockedEndpointPrivileges.canWriteTrustedApplications = true;
});

it('should enable adding entries', async () => {
render();

await waitFor(() =>
expect(renderResult.queryByTestId('trustedAppsListPage-pageAddButton')).toBeTruthy()
);
});

it('should enable modifying/deleting entries', async () => {
render();

const actionsButton = await waitFor(
() => renderResult.getAllByTestId('trustedAppsListPage-card-header-actions-button')[0]
);
userEvent.click(actionsButton);

expect(renderResult.getByTestId('trustedAppsListPage-card-cardEditAction')).toBeTruthy();
expect(renderResult.getByTestId('trustedAppsListPage-card-cardDeleteAction')).toBeTruthy();
});
});

describe('READ privilege', () => {
beforeEach(() => {
mockedEndpointPrivileges.canWriteTrustedApplications = false;
});

it('should disable adding entries', async () => {
render();

await waitFor(() =>
expect(renderResult.queryByTestId('trustedAppsListPage-container')).toBeTruthy()
);

expect(renderResult.queryByTestId('trustedAppsListPage-pageAddButton')).toBeNull();
});

it('should disable modifying/deleting entries', async () => {
render();

await waitFor(() =>
expect(renderResult.queryByTestId('trustedAppsListPage-container')).toBeTruthy()
);

expect(
renderResult.queryByTestId('trustedAppsListPage-card-header-actions-button')
).toBeNull();
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { FormattedMessage } from '@kbn/i18n-react';
import type { DocLinks } from '@kbn/doc-links';
import { EuiLink } from '@elastic/eui';

import { useUserPrivileges } from '../../../../common/components/user_privileges';
import { useHttp } from '../../../../common/lib/kibana';
import type { ArtifactListPageProps } from '../../../components/artifact_list_page';
import { ArtifactListPage } from '../../../components/artifact_list_page';
Expand Down Expand Up @@ -108,6 +109,7 @@ const TRUSTED_APPS_PAGE_LABELS: ArtifactListPageProps['labels'] = {
};

export const TrustedAppsList = memo(() => {
const { canWriteTrustedApplications } = useUserPrivileges().endpointPrivileges;
const http = useHttp();
const trustedAppsApiClient = TrustedAppsApiClient.getInstance(http);

Expand All @@ -119,6 +121,9 @@ export const TrustedAppsList = memo(() => {
data-test-subj="trustedAppsListPage"
searchableFields={SEARCHABLE_FIELDS}
secondaryPageInfo={<TrustedAppsArtifactsDocsLink />}
allowCardDeleteAction={canWriteTrustedApplications}
allowCardEditAction={canWriteTrustedApplications}
allowCardCreateAction={canWriteTrustedApplications}
/>
);
});
Expand Down

0 comments on commit 4c6a915

Please sign in to comment.