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,