diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 67eb5c354..6c479f4b4 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -92,17 +92,9 @@ Next, go to the base directory (`cd ../..`) and run `yarn osd bootstrap` to inst From the base directory, run `yarn start`. This should start dashboard UI successfully. `Cmd+click` the url in the console output (It should look something like `http://0:5601/omf`). Once the page loads, you should be able to log in with user `admin` and password `admin`. -## Testing +## Integration Tests -The security-dashboards-plugin project uses Jest, Cypress and Selenium and makes use of the [OpenSearch Dashboards Functional Test]( https://github.com/opensearch-project/opensearch-dashboards-functional-test) project. - -Make sure you have the OpenSearch and OpenSearch Dashboards running with the Security Plugin and that you can log in to it using a web browser. - -Clone [OpenSearch Dashboards Functional Test]( https://github.com/opensearch-project/opensearch-dashboards-functional-test) in your local machine and follow the instructions in its DEVELOPER_GUIDE.md - -### Integration Tests - -To run selenium based integration tests, download and export the firefox web-driver to your PATH. Also, run `node scripts/build_opensearch_dashboards_platform_plugins.js` or `yarn start` before running the tests. This is essential to generate the bundles. +To run selenium based integration tests, download and export the firefox web-driver to your PATH. Also, run `node scripts/build_opensearch_dashboards_platform_plugins.js` or `yarn start` before running the tests. This is essential to generate the bundles. The integration tests take advantage of [npm "pre" scripts](https://docs.npmjs.com/cli/v9/using-npm/scripts) to run a node based SAML IdP for integration tests related to SAML authentication. This will run a background process that listens on port 7000. diff --git a/public/apps/configuration/app-router.tsx b/public/apps/configuration/app-router.tsx index 6cb97ec0e..a10cec08e 100644 --- a/public/apps/configuration/app-router.tsx +++ b/public/apps/configuration/app-router.tsx @@ -35,6 +35,7 @@ import { RoleEditMappedUser } from './panels/role-mapping/role-edit-mapped-user' import { RoleView } from './panels/role-view/role-view'; import { TenantList } from './panels/tenant-list/tenant-list'; import { UserList } from './panels/user-list'; +import { ServiceAccountList } from './panels/service-account-list'; import { Action, ResourceType, RouteItem, SubAction } from './types'; import { buildHashUrl, buildUrl } from './utils/url-builder'; import { CrossPageToast } from './cross-page-toast'; @@ -54,6 +55,10 @@ const ROUTE_MAP: { [key: string]: RouteItem } = { name: 'Internal users', href: buildUrl(ResourceType.users), }, + [ResourceType.serviceAccounts]: { + name: 'Service Accounts', + href: buildUrl(ResourceType.serviceAccounts), + }, [ResourceType.permissions]: { name: 'Permissions', href: buildUrl(ResourceType.permissions), @@ -85,6 +90,7 @@ const ROUTE_LIST = [ ROUTE_MAP[ResourceType.auth], ROUTE_MAP[ResourceType.roles], ROUTE_MAP[ResourceType.users], + ROUTE_MAP[ResourceType.serviceAccounts], ROUTE_MAP[ResourceType.permissions], ROUTE_MAP[ResourceType.tenants], ROUTE_MAP[ResourceType.auditLogging], @@ -209,6 +215,13 @@ export function AppRouter(props: AppDependencies) { return ; }} /> + { + setGlobalBreadcrumbs(ResourceType.serviceAccounts); + return ; + }} + /> { diff --git a/public/apps/configuration/constants.tsx b/public/apps/configuration/constants.tsx index e789591cd..4dd107a91 100644 --- a/public/apps/configuration/constants.tsx +++ b/public/apps/configuration/constants.tsx @@ -25,6 +25,8 @@ export const API_ENDPOINT_MULTITENANCY = API_PREFIX + '/multitenancy/tenant'; export const API_ENDPOINT_TENANCY_CONFIGS = API_ENDPOINT + '/tenancy/config'; export const API_ENDPOINT_SECURITYCONFIG = API_ENDPOINT + '/securityconfig'; export const API_ENDPOINT_INTERNALUSERS = API_ENDPOINT + '/internalusers'; +export const API_ENDPOINT_INTERNALACCOUNTS = API_ENDPOINT + '/internalaccounts'; +export const API_ENDPOINT_SERVICEACCOUNTS = API_ENDPOINT + '/serviceaccounts'; export const API_ENDPOINT_AUDITLOGGING = API_ENDPOINT + '/audit'; export const API_ENDPOINT_AUDITLOGGING_UPDATE = API_ENDPOINT_AUDITLOGGING + '/config'; export const API_ENDPOINT_PERMISSIONS_INFO = API_PREFIX + '/restapiinfo'; diff --git a/public/apps/configuration/panels/service-account-list.tsx b/public/apps/configuration/panels/service-account-list.tsx new file mode 100644 index 000000000..61c21a9e5 --- /dev/null +++ b/public/apps/configuration/panels/service-account-list.tsx @@ -0,0 +1,220 @@ +/* + * Copyright OpenSearch Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { + EuiBadge, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiInMemoryTable, + EuiLink, + EuiPageBody, + EuiPageContent, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiPageHeader, + EuiText, + EuiTitle, + Query, +} from '@elastic/eui'; +import { Dictionary, difference, isEmpty, map } from 'lodash'; +import React, { useState } from 'react'; +import { getAuthInfo } from '../../../utils/auth-info-utils'; +import { AppDependencies } from '../../types'; +import { API_ENDPOINT_SERVICEACCOUNTS, DocLinks } from '../constants'; +import { Action, ResourceType } from '../types'; +import { EMPTY_FIELD_VALUE } from '../ui-constants'; +import { useContextMenuState } from '../utils/context-menu'; +import { ExternalLink, tableItemsUIProps, truncatedListView } from '../utils/display-utils'; +import { getUserList, InternalUsersListing } from '../utils/internal-user-list-utils'; +import { showTableStatusMessage } from '../utils/loading-spinner-utils'; +import { buildHashUrl } from '../utils/url-builder'; + +export function dictView(items: Dictionary) { + if (isEmpty(items)) { + return EMPTY_FIELD_VALUE; + } + return ( + + {map(items, (v, k) => ( + + {k}: {`"${v}"`} + + ))} + + ); +} + +export function getColumns(currentUsername: string) { + return [ + { + field: 'username', + name: 'Username', + render: (username: string) => ( + <> + {username} + {username === currentUsername && ( + <> +   + Current + + )} + + ), + sortable: true, + }, + { + field: 'backend_roles', + name: 'Backend roles', + render: truncatedListView(tableItemsUIProps), + }, + { + field: 'attributes', + name: 'Attributes', + render: dictView, + truncateText: true, + }, + ]; +} + +export function ServiceAccountList(props: AppDependencies) { + const [userData, setUserData] = React.useState([]); + const [errorFlag, setErrorFlag] = React.useState(false); + const [selection, setSelection] = React.useState([]); + const [currentUsername, setCurrentUsername] = useState(''); + const [loading, setLoading] = useState(false); + const [query, setQuery] = useState(null); + + React.useEffect(() => { + const fetchData = async () => { + try { + setLoading(true); + const userDataPromise = getUserList(props.coreStart.http, ResourceType.serviceAccounts); + setCurrentUsername((await getAuthInfo(props.coreStart.http)).user_name); + setUserData(await userDataPromise); + } catch (e) { + console.log(e); + setErrorFlag(true); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [props.coreStart.http]); + + const actionsMenuItems = [ + { + window.location.href = buildHashUrl(ResourceType.users, Action.edit, selection[0].username); + }} + disabled={selection.length !== 1} + > + Edit + , + { + window.location.href = buildHashUrl( + ResourceType.users, + Action.duplicate, + selection[0].username + ); + }} + disabled={selection.length !== 1} + > + Duplicate + , + + Export JSON + , + ]; + + const [actionsMenu, closeActionsMenu] = useContextMenuState('Actions', {}, actionsMenuItems); + + return ( + <> + + +

Service accounts

+
+
+ + + + +

+ Service accounts + + {' '} + ({Query.execute(query || '', userData).length}) + +

+
+ + Here you have a list of special accounts that represent services like extensions, + plugins or other third party applications. You can map an account to a role from + Roles + “Manage mapping” + + +
+ + + {actionsMenu} + + +
+ + { + setQuery(arg.query); + return true; + }, + }} + // @ts-ignore + selection={{ onSelectionChange: setSelection }} + sorting + error={ + errorFlag ? 'Load data failed, please check the console log for more details.' : '' + } + message={showTableStatusMessage(loading, userData)} + /> + +
+ + ); +} diff --git a/public/apps/configuration/panels/test/service-account-list.test.tsx b/public/apps/configuration/panels/test/service-account-list.test.tsx new file mode 100644 index 000000000..2ffe595e8 --- /dev/null +++ b/public/apps/configuration/panels/test/service-account-list.test.tsx @@ -0,0 +1,173 @@ +/* + * Copyright OpenSearch Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { EuiBadge, EuiText, EuiInMemoryTable } from '@elastic/eui'; +import { shallow } from 'enzyme'; +import React from 'react'; +import { EMPTY_FIELD_VALUE } from '../../ui-constants'; +import { getUserList, InternalUsersListing } from '../../utils/internal-user-list-utils'; +import { dictView, getColumns, ServiceAccountList } from '../service-account-list'; + +jest.mock('../../utils/internal-user-list-utils'); +jest.mock('../../../../utils/auth-info-utils', () => ({ + getAuthInfo: jest.fn().mockReturnValue({ user_name: 'user' }), +})); +jest.mock('../../utils/context-menu', () => ({ + useContextMenuState: jest + .fn() + .mockImplementation((buttonText, buttonProps, children) => [children, jest.fn()]), +})); + +import { getAuthInfo } from '../../../../utils/auth-info-utils'; +import { buildHashUrl } from '../../utils/url-builder'; +import { ResourceType, Action } from '../../types'; + +describe('User list', () => { + describe('dictView', () => { + it('- empty', () => { + const result = dictView({}); + + expect(result).toEqual(EMPTY_FIELD_VALUE); + }); + + it('dictView - non-empty', () => { + const attr1 = 'attr1'; + const attr2 = 'attr2'; + const value1 = 'value1'; + const value2 = 'value2'; + const result = shallow(dictView({ [attr1]: value1, [attr2]: value2 })); + + expect(result.find(EuiText).at(0).prop('children')).toEqual([attr1, ': ', `"${value1}"`]); + expect(result.find(EuiText).at(1).prop('children')).toEqual([attr2, ': ', `"${value2}"`]); + }); + }); + + describe('getColumns', () => { + it('current user', () => { + const columns = getColumns('user1'); + const usernameRenderer = columns[0].render as (usename: string) => JSX.Element; + const Container = (props: { username: string }) => usernameRenderer(props.username); + const result = shallow(); + + expect(result.find(EuiBadge).length).toBe(1); + }); + + it('not current user', () => { + const columns = getColumns('user1'); + const usernameRenderer = columns[0].render as (usename: string) => JSX.Element; + const Container = (props: { username: string }) => usernameRenderer(props.username); + const result = shallow(); + + expect(result.find(EuiBadge).length).toBe(0); + }); + }); + + describe('ServiceAccountList', () => { + const mockCoreStart = { + http: 1, + }; + const setState = jest.fn(); + jest.spyOn(React, 'useState').mockImplementation((initValue) => [initValue, setState]); + + it('render empty', () => { + const component = shallow( + + ); + + expect(component.find(EuiInMemoryTable).prop('items')).toEqual([]); + }); + + it('fetch data', () => { + jest.spyOn(React, 'useEffect').mockImplementationOnce((f) => f()); + shallow( + + ); + + expect(getUserList).toBeCalled(); + expect(getAuthInfo).toBeCalled(); + }); + + it('fetch data error', () => { + jest.spyOn(React, 'useEffect').mockImplementationOnce((f) => f()); + getUserList.mockImplementationOnce(() => { + throw new Error(); + }); + // Hide the error message + jest.spyOn(console, 'log').mockImplementationOnce(() => {}); + shallow( + + ); + + // Expect error flag set to true + expect(setState).toBeCalledWith(true); + }); + }); + + describe('Action menu click', () => { + const mockCoreStart = { + http: { + basePath: { + serverBasePath: '', + }, + }, + }; + let component; + const mockUserListingData: InternalUsersListing = { + username: 'user_1', + attributes: { service: 'true' }, + backend_roles: ['backend_role1'], + }; + beforeEach(() => { + jest.spyOn(React, 'useState').mockImplementation(() => [[mockUserListingData], jest.fn()]); + component = shallow( + + ); + }); + + it('Edit click', () => { + component.find('[data-test-subj="edit"]').simulate('click'); + expect(window.location.hash).toBe( + buildHashUrl(ResourceType.users, Action.edit, mockUserListingData.username) + ); + }); + + it('Duplicate click', () => { + component.find('[data-test-subj="duplicate"]').simulate('click'); + expect(window.location.hash).toBe( + buildHashUrl(ResourceType.users, Action.duplicate, mockUserListingData.username) + ); + }); + }); +}); diff --git a/public/apps/configuration/panels/user-list.tsx b/public/apps/configuration/panels/user-list.tsx index 8cb627f8b..eb369f7ad 100644 --- a/public/apps/configuration/panels/user-list.tsx +++ b/public/apps/configuration/panels/user-list.tsx @@ -107,7 +107,7 @@ export function UserList(props: AppDependencies) { const fetchData = async () => { try { setLoading(true); - const userDataPromise = getUserList(props.coreStart.http); + const userDataPromise = getUserList(props.coreStart.http, ResourceType.users); setCurrentUsername((await getAuthInfo(props.coreStart.http)).user_name); setUserData(await userDataPromise); } catch (e) { @@ -213,7 +213,7 @@ export function UserList(props: AppDependencies) { The Security plugin includes an internal user database. Use this database in place of, or in addition to, an external authentication system such as LDAP server or Active - Directory. You can map an internal user to a role from{' '} + Directory. You can map an user account to a role from{' '} Roles . First, click into the detail page of the role. Then, under “Mapped users”, click “Manage mapping” @@ -224,7 +224,7 @@ export function UserList(props: AppDependencies) { {actionsMenu} - Create internal user + Create user account diff --git a/public/apps/configuration/types.ts b/public/apps/configuration/types.ts index 06ef44369..fd64e1095 100644 --- a/public/apps/configuration/types.ts +++ b/public/apps/configuration/types.ts @@ -22,6 +22,7 @@ export type FieldLevelSecurityMethod = 'exclude' | 'include'; export enum ResourceType { roles = 'roles', users = 'users', + serviceAccounts = 'serviceAccounts', permissions = 'permissions', tenants = 'tenants', tenantsManageTab = 'tenantsManageTab', diff --git a/public/apps/configuration/utils/internal-user-list-utils.tsx b/public/apps/configuration/utils/internal-user-list-utils.tsx index 9a893eeb9..02042e928 100644 --- a/public/apps/configuration/utils/internal-user-list-utils.tsx +++ b/public/apps/configuration/utils/internal-user-list-utils.tsx @@ -15,8 +15,12 @@ import { map } from 'lodash'; import { HttpStart } from '../../../../../../src/core/public'; -import { API_ENDPOINT_INTERNALUSERS } from '../constants'; -import { DataObject, InternalUser, ObjectsMessage } from '../types'; +import { + API_ENDPOINT_INTERNALACCOUNTS, + API_ENDPOINT_INTERNALUSERS, + API_ENDPOINT_SERVICEACCOUNTS, +} from '../constants'; +import { DataObject, InternalUser, ObjectsMessage, ResourceType } from '../types'; import { httpDelete, httpGet } from './request-utils'; import { getResourceUrl } from './resource-utils'; @@ -38,15 +42,26 @@ export async function requestDeleteUsers(http: HttpStart, users: string[]) { } } -async function getUserListRaw(http: HttpStart): Promise> { - return await httpGet>(http, API_ENDPOINT_INTERNALUSERS); +async function getUserListRaw( + http: HttpStart, + userType: string +): Promise> { + let ENDPOINT = API_ENDPOINT_INTERNALACCOUNTS; + if (userType === ResourceType.serviceAccounts) { + ENDPOINT = API_ENDPOINT_SERVICEACCOUNTS; + } + + return await httpGet>(http, ENDPOINT); } -export async function getUserList(http: HttpStart): Promise { - const rawData = await getUserListRaw(http); +export async function getUserList( + http: HttpStart, + userType: string +): Promise { + const rawData = await getUserListRaw(http, userType); return transformUserData(rawData.data); } -export async function fetchUserNameList(http: HttpStart): Promise { - return Object.keys((await getUserListRaw(http)).data); +export async function fetchUserNameList(http: HttpStart, userType: string): Promise { + return Object.keys((await getUserListRaw(http, userType)).data); } diff --git a/server/backend/opensearch_security_configuration_plugin.ts b/server/backend/opensearch_security_configuration_plugin.ts index ff0ccfe21..3b87863b8 100644 --- a/server/backend/opensearch_security_configuration_plugin.ts +++ b/server/backend/opensearch_security_configuration_plugin.ts @@ -59,6 +59,17 @@ export default function (Client: any, config: any, components: any) { }, }); + Client.prototype.opensearch_security.prototype.listInternalAccounts = ca({ + url: { + fmt: '/_plugins/_security/api/internalusers?filterBy=internal', + }, + }); + Client.prototype.opensearch_security.prototype.listServiceAccounts = ca({ + url: { + fmt: '/_plugins/_security/api/internalusers?filterBy=service', + }, + }); + /** * Creates a Security resource instance. * diff --git a/server/routes/index.ts b/server/routes/index.ts index 8331c1200..ad3dfbd58 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -21,6 +21,7 @@ import { OpenSearchDashboardsResponseFactory, } from 'opensearch-dashboards/server'; import { API_PREFIX, CONFIGURATION_API_PREFIX, isValidResourceName } from '../../common'; +import { ResourceType } from '../../public/apps/configuration/types'; // TODO: consider to extract entity CRUD operations and put it into a client class export function defineRoutes(router: IRouter) { @@ -248,9 +249,15 @@ export function defineRoutes(router: IRouter) { const client = context.security_plugin.esClient.asScoped(request); let esResp; try { - esResp = await client.callAsCurrentUser('opensearch_security.listResource', { - resourceName: request.params.resourceName, - }); + if (request.params.resourceName === ResourceType.serviceAccounts.toLowerCase()) { + esResp = await client.callAsCurrentUser('opensearch_security.listServiceAccounts'); + } else if (request.params.resourceName === 'internalaccounts') { + esResp = await client.callAsCurrentUser('opensearch_security.listInternalAccounts'); + } else { + esResp = await client.callAsCurrentUser('opensearch_security.listResource', { + resourceName: request.params.resourceName, + }); + } return response.ok({ body: { total: Object.keys(esResp).length,