Skip to content

Commit 5f1d214

Browse files
Adds multi-datasource support in Resource Access Management app
Signed-off-by: Darshit Chanpura <dchanp@amazon.com>
1 parent 469af22 commit 5f1d214

File tree

9 files changed

+409
-127
lines changed

9 files changed

+409
-127
lines changed

public/apps/resource-sharing/resource-access-management-app.tsx

Lines changed: 67 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -15,27 +15,28 @@
1515

1616
import './_index.scss';
1717

18-
import React from 'react';
19-
import ReactDOM from 'react-dom';
18+
import React, { useContext, useState, createContext } from 'react';
19+
import { createRoot } from 'react-dom/client';
2020
import { I18nProvider } from '@osd/i18n/react';
21-
22-
import {
23-
EuiPage,
24-
EuiPageBody,
25-
EuiFlexGroup,
26-
EuiFlexItem,
27-
EuiPageHeader,
28-
EuiTitle,
29-
EuiText,
30-
EuiSpacer,
31-
} from '@elastic/eui';
21+
import { EuiPageHeader, EuiText, EuiSpacer } from '@elastic/eui';
22+
import { DataSourceOption } from 'src/plugins/data_source_management/public';
3223

3324
import { AppMountParameters, CoreStart } from '../../../../../src/core/public';
3425
import { DataSourceManagementPluginSetup } from '../../../../../src/plugins/data_source_management/public';
3526
import { SecurityPluginStartDependencies, ClientConfigType } from '../../types';
3627

3728
import { ResourceSharingPanel } from './resource-sharing-panel';
3829
import { buildResourceApi } from '../../utils/resource-sharing-utils';
30+
import { SecurityPluginTopNavMenu } from '../configuration/top-nav-menu';
31+
import { PageHeader } from '../configuration/header/header-components';
32+
import { getDataSourceFromUrl, LocalCluster } from '../../utils/datasource-utils';
33+
34+
export interface DataSourceContextType {
35+
dataSource: DataSourceOption;
36+
setDataSource: React.Dispatch<React.SetStateAction<DataSourceOption>>;
37+
}
38+
39+
export const DataSourceContext = createContext<DataSourceContextType | null>(null);
3940

4041
interface Props {
4142
coreStart: CoreStart;
@@ -46,44 +47,57 @@ interface Props {
4647
dataSourceManagement?: DataSourceManagementPluginSetup;
4748
}
4849

49-
const ResourceAccessManagementApp: React.FC<Props> = ({ coreStart, depsStart }) => {
50+
const ResourceAccessManagementApp: React.FC<Props> = (props) => {
5051
const {
5152
http,
5253
notifications: { toasts },
53-
} = coreStart;
54-
const TopNav = depsStart?.navigation?.ui?.TopNavMenu;
54+
} = props.coreStart;
55+
const { dataSource, setDataSource } = useContext(DataSourceContext)!;
56+
57+
const api = React.useMemo(() => buildResourceApi(http, dataSource?.id) as any, [
58+
http,
59+
dataSource?.id,
60+
]);
5561

5662
return (
57-
<>
58-
{TopNav ? (
59-
<TopNav appName="resource-access" showSearchBar={false} useDefaultBehaviors={true} />
60-
) : null}
61-
<EuiPage restrictWidth="2000px">
62-
<EuiPageBody component="main">
63-
<EuiPageHeader>
64-
<EuiFlexGroup direction="column" gutterSize="xs">
65-
<EuiFlexItem grow={false}>
66-
<EuiTitle size="l">
67-
<h1>Resource Access Management</h1>
68-
</EuiTitle>
69-
</EuiFlexItem>
70-
<EuiFlexItem grow={false}>
71-
<EuiText color="subdued" size="s">
72-
Manage sharing for detectors, forecasters, and more.
73-
</EuiText>
74-
</EuiFlexItem>
75-
</EuiFlexGroup>
76-
</EuiPageHeader>
77-
78-
<EuiSpacer size="m" />
79-
80-
<ResourceSharingPanel api={buildResourceApi(http)} toasts={toasts} />
81-
</EuiPageBody>
82-
</EuiPage>
83-
</>
63+
<div className="panel-restrict-width">
64+
<SecurityPluginTopNavMenu
65+
{...(props as any)}
66+
dataSourcePickerReadOnly={false}
67+
setDataSource={setDataSource}
68+
selectedDataSource={dataSource}
69+
/>
70+
<PageHeader
71+
coreStart={props.coreStart}
72+
navigation={props.depsStart.navigation}
73+
fallBackComponent={
74+
<>
75+
<EuiPageHeader>
76+
<EuiText size="s">
77+
<h1>Resource Access Management</h1>
78+
</EuiText>
79+
</EuiPageHeader>
80+
<EuiSpacer />
81+
</>
82+
}
83+
/>
84+
<ResourceSharingPanel api={api} toasts={toasts} />
85+
</div>
8486
);
8587
};
8688

89+
function ResourceAccessManagementAppWithContext(props: Props) {
90+
const dataSourceEnabled = !!props.depsStart.dataSource?.dataSourceEnabled;
91+
const dataSourceFromUrl = dataSourceEnabled ? getDataSourceFromUrl() : LocalCluster;
92+
const [dataSource, setDataSource] = useState<DataSourceOption>(dataSourceFromUrl);
93+
94+
return (
95+
<DataSourceContext.Provider value={{ dataSource, setDataSource }}>
96+
<ResourceAccessManagementApp {...props} />
97+
</DataSourceContext.Provider>
98+
);
99+
}
100+
87101
export function renderApp(
88102
coreStart: CoreStart,
89103
depsStart: SecurityPluginStartDependencies,
@@ -92,21 +106,17 @@ export function renderApp(
92106
redirect: string,
93107
dataSourceManagement?: DataSourceManagementPluginSetup
94108
) {
95-
const deps: Props = {
96-
coreStart,
97-
depsStart,
98-
params,
99-
config,
100-
dataSourceManagement,
101-
redirect,
102-
};
103-
104-
ReactDOM.render(
109+
const deps = { coreStart, depsStart, params, config, redirect, dataSourceManagement };
110+
111+
const element = (
112+
// @ts-ignore
105113
<I18nProvider>
106-
<ResourceAccessManagementApp {...deps} />
107-
</I18nProvider>,
108-
params.element
114+
<ResourceAccessManagementAppWithContext {...deps} />
115+
</I18nProvider>
109116
);
110117

111-
return () => ReactDOM.unmountComponentAtNode(params.element);
118+
const root = createRoot(params.element);
119+
root.render(element);
120+
121+
return () => root.unmount();
112122
}

public/apps/resource-sharing/test/resource-access-management-app.test.tsx

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
*/
1919
import '@testing-library/jest-dom';
2020
import { renderApp } from '../resource-access-management-app';
21-
import React from 'react';
2221

2322
function mockCoreStart() {
2423
return {
@@ -27,49 +26,56 @@ function mockCoreStart() {
2726
} as any;
2827
}
2928

30-
function mockDepsStart(withTopNav = true) {
31-
const TopNavMenu = ({ appName }: any) => <div data-test-subj="top-nav">{appName}</div>;
32-
return withTopNav
33-
? ({ navigation: { ui: { TopNavMenu } } } as any)
34-
: ({ navigation: { ui: {} } } as any);
29+
function mockDepsStart(withDataSource = true) {
30+
return {
31+
navigation: { ui: {} } as any,
32+
dataSource: withDataSource ? { dataSourceEnabled: true } : undefined,
33+
} as any;
3534
}
3635

3736
describe('ResourceAccessManagementApp', () => {
38-
it('renders TopNav when present and the page title', () => {
37+
it('renders the page title and data source picker when data source is enabled', () => {
3938
const elem = document.createElement('div');
40-
document.body.appendChild(elem); // because we render a detached div, we need to attach it to the body for the test to work properly
41-
42-
// Render the app into the detached div
39+
document.body.appendChild(elem);
4340

4441
const unmount = renderApp(
4542
mockCoreStart(),
4643
mockDepsStart(true),
4744
{ element: elem } as any,
4845
{} as any,
49-
''
46+
'',
47+
{} as any
5048
);
5149

5250
// Rendered into elem, so query inside it
53-
expect(elem.querySelector('[data-test-subj="top-nav"]')).toBeInTheDocument();
5451
expect(elem.textContent).toContain('Resource Access Management');
5552

53+
// Check for data source picker (SecurityPluginTopNavMenu renders when dataSourceEnabled is true)
54+
const dataSourcePicker = elem.querySelector('[class*="dataSourceMenu"]');
55+
expect(dataSourcePicker).toBeTruthy();
56+
5657
unmount();
5758
});
5859

59-
it('omits TopNav when not provided', () => {
60+
it('renders without data source picker when data source is disabled', () => {
6061
const elem = document.createElement('div');
62+
document.body.appendChild(elem);
6163

6264
const unmount = renderApp(
6365
mockCoreStart(),
6466
mockDepsStart(false),
6567
{ element: elem } as any,
6668
{} as any,
67-
''
69+
'',
70+
{} as any
6871
);
6972

70-
expect(elem.querySelector('[data-test-subj="top-nav"]')).toBeNull();
7173
expect(elem.textContent).toContain('Resource Access Management');
7274

75+
// Check that data source picker is not rendered when dataSourceEnabled is false
76+
const dataSourcePicker = elem.querySelector('[class*="dataSourceMenu"]');
77+
expect(dataSourcePicker).toBeFalsy();
78+
7379
unmount();
7480
});
7581
});

public/apps/resource-sharing/test/resource-sharing-panel.test.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { ResourceSharingPanel } from '../resource-sharing-panel';
2424
import { I18nProvider } from '@osd/i18n/react';
2525

2626
function renderWithI18n(ui: React.ReactElement) {
27+
// @ts-ignore
2728
return render(<I18nProvider>{ui}</I18nProvider>);
2829
}
2930

@@ -257,6 +258,35 @@ describe('ResourceSharingPanel', () => {
257258
expect(toasts.addSuccess).toHaveBeenCalledWith('Access updated.');
258259
});
259260

261+
it('passes dataSourceId to API calls when provided', async () => {
262+
const api = {
263+
listTypes: jest.fn().mockResolvedValue({ types: typesPayload }),
264+
listSharingRecords: jest.fn().mockResolvedValue({ resources: rowsPayload }),
265+
getSharingRecord: jest.fn(),
266+
share: jest.fn(),
267+
update: jest.fn(),
268+
};
269+
270+
renderWithI18n(<ResourceSharingPanel api={api as any} toasts={toasts as any} />);
271+
272+
// listTypes should be called on mount
273+
await waitFor(() => {
274+
expect(api.listTypes).toHaveBeenCalledTimes(1);
275+
});
276+
277+
// Select a type to trigger listSharingRecords
278+
const selectTrigger = await screen.findByText('Select a type…');
279+
await userEvent.click(selectTrigger);
280+
await userEvent.click(await screen.findByText('Anomaly Detector'));
281+
282+
await waitFor(() => {
283+
expect(api.listSharingRecords).toHaveBeenCalledWith('anomaly-detector');
284+
});
285+
286+
// Verify the API was called (dataSourceId is passed internally via buildResourceApi)
287+
expect(api.listSharingRecords).toHaveBeenCalled();
288+
});
289+
260290
it('renders friendly error lines when backend returns structured errors', async () => {
261291
const api = {
262292
listTypes: jest.fn().mockResolvedValue({ types: typesPayload }),

public/utils/resource-sharing-utils.tsx

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,28 @@
1414
*/
1515

1616
import { CoreStart } from '../../../../src/core/public';
17+
import {
18+
createRequestContextWithDataSourceId,
19+
createLocalClusterRequestContext,
20+
} from '../apps/configuration/utils/request-utils';
1721

18-
export const buildResourceApi = (http: CoreStart['http']) => ({
19-
listTypes: () => http.get('/api/resource/types'),
20-
listSharingRecords: (type: string) =>
21-
http.get('/api/resource/list', { query: { resourceType: type } }),
22-
getSharingRecord: (id: string, type: string) =>
23-
http.get('/api/resource/view', { query: { resourceId: id, resourceType: type } }),
24-
share: (payload: any) => http.put('/api/resource/share', { body: JSON.stringify(payload) }),
25-
update: (payload: any) =>
26-
http.post('/api/resource/update_sharing', { body: JSON.stringify(payload) }),
27-
});
22+
export const buildResourceApi = (http: CoreStart['http'], dataSourceId?: string) => {
23+
const context = dataSourceId
24+
? createRequestContextWithDataSourceId(dataSourceId)
25+
: createLocalClusterRequestContext();
26+
27+
return {
28+
listTypes: () => context.httpGet({ http, url: '/api/resource/types' }),
29+
listSharingRecords: (type: string) =>
30+
context.httpGet({ http, url: '/api/resource/list', body: { resourceType: type } }),
31+
getSharingRecord: (id: string, type: string) =>
32+
context.httpGet({
33+
http,
34+
url: '/api/resource/view',
35+
body: { resourceId: id, resourceType: type },
36+
}),
37+
share: (payload: any) => context.httpPut({ http, url: '/api/resource/share', body: payload }),
38+
update: (payload: any) =>
39+
context.httpPost({ http, url: '/api/resource/update_sharing', body: payload }),
40+
};
41+
};

server/backend/opensearch_security_plugin.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,66 @@ export default function (Client: any, config: any, components: any) {
8484
fmt: '/_plugins/_security/api/tenancy/config',
8585
},
8686
});
87+
88+
/**
89+
* Gets registered resource types.
90+
*/
91+
Client.prototype.opensearch_security.prototype.listResourceTypes = ca({
92+
url: {
93+
fmt: '/_plugins/_security/api/resource/types',
94+
},
95+
});
96+
97+
/**
98+
* Gets accessible shared resources filtered by resourceType.
99+
*/
100+
Client.prototype.opensearch_security.prototype.listResourceSharing = ca({
101+
url: {
102+
fmt: '/_plugins/_security/api/resource/list',
103+
req: {
104+
resource_type: {
105+
type: 'query',
106+
},
107+
},
108+
},
109+
});
110+
111+
/**
112+
* Gets sharing info for a specific resource.
113+
*/
114+
Client.prototype.opensearch_security.prototype.getResourceSharing = ca({
115+
url: {
116+
fmt: '/_plugins/_security/api/resource/share',
117+
req: {
118+
resource_id: {
119+
type: 'query',
120+
},
121+
resource_type: {
122+
type: 'query',
123+
},
124+
},
125+
},
126+
});
127+
128+
/**
129+
* Shares a resource.
130+
*/
131+
Client.prototype.opensearch_security.prototype.shareResource = ca({
132+
method: 'PUT',
133+
needBody: true,
134+
url: {
135+
fmt: '/_plugins/_security/api/resource/share',
136+
},
137+
});
138+
139+
/**
140+
* Updates resource sharing.
141+
*/
142+
Client.prototype.opensearch_security.prototype.updateResourceSharing = ca({
143+
method: 'POST',
144+
needBody: true,
145+
url: {
146+
fmt: '/_plugins/_security/api/resource/share',
147+
},
148+
});
87149
}

server/plugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ export class SecurityPlugin implements Plugin<SecurityPluginSetup, SecurityPlugi
146146
// Register server side APIs
147147
defineRoutes(router, dataSourceEnabled);
148148
defineAuthTypeRoutes(router, config);
149-
defineResourceAccessManagementRoutes(router);
149+
defineResourceAccessManagementRoutes(router, dataSourceEnabled);
150150

151151
// set up multi-tenant routes
152152
if (config.multitenancy?.enabled) {

0 commit comments

Comments
 (0)