diff --git a/.github/workflows/cypress-test.yml b/.github/workflows/cypress-test.yml new file mode 100644 index 000000000..0ba215226 --- /dev/null +++ b/.github/workflows/cypress-test.yml @@ -0,0 +1,131 @@ +name: Cypress Tests + +on: [push, pull_request] + +env: + TEST_BROWSER_HEADLESS: 1 + CI: 1 + FTR_PATH: 'ftr' + START_CMD: 'node ../scripts/opensearch_dashboards --dev --no-base-path --no-watch --opensearch_security.multitenancy.enable_aggregation_view=true' + OPENSEARCH_SNAPSHOT_CMD: 'node ../scripts/opensearch snapshot' + SPEC: 'cypress/integration/plugins/security-dashboards-plugin/aggregation_view.js,' + +jobs: + tests: + name: Run aggregation view cypress test + runs-on: ubuntu-latest + steps: + - name: Download OpenSearch Core + run: | + wget https://ci.opensearch.org/ci/dbc/distribution-build-opensearch/3.0.0/latest/linux/x64/tar/builds/opensearch/dist/opensearch-min-3.0.0-linux-x64.tar.gz + tar -xzf opensearch-*.tar.gz + rm -f opensearch-*.tar.gz + + - name: Download OpenSearch Security Plugin + run: wget -O opensearch-security.zip https://ci.opensearch.org/ci/dbc/distribution-build-opensearch/3.0.0/latest/linux/x64/tar/builds/opensearch/plugins/opensearch-security-3.0.0.0.zip + + + - name: Run OpenSearch with plugin + run: | + cat > os-ep.sh <<EOF + yes | opensearch-plugin install file:///docker-host/security-plugin.zip + chmod +x plugins/opensearch-security/tools/install_demo_configuration.sh + yes | plugins/opensearch-security/tools/install_demo_configuration.sh + echo "plugins.security.unsupported.restapi.allow_securityconfig_modification: true" >> /opensearch/config/opensearch.yml + chown 1001:1001 -R /opensearch + su -c "/opensearch/bin/opensearch" -s /bin/bash opensearch + EOF + docker build -t opensearch-test:latest -f- . <<EOF + FROM ubuntu:latest + COPY --chown=1001:1001 os-ep.sh /docker-host/ + COPY --chown=1001:1001 opensearch-security.zip /docker-host/security-plugin.zip + COPY --chown=1001:1001 opensearch* /opensearch/ + RUN chmod +x /docker-host/os-ep.sh + RUN useradd -u 1001 -s /sbin/nologin opensearch + ENV PATH="/opensearch/bin:${PATH}" + WORKDIR /opensearch/ + ENTRYPOINT /docker-host/os-ep.sh + EOF + docker run -d -p 9200:9200 -p 9600:9600 -i opensearch-test:latest + + - name: Checkout OpenSearch Dashboard + uses: actions/checkout@v2 + with: + path: OpenSearch-Dashboards + repository: opensearch-project/OpenSearch-Dashboards + ref: 'main' + fetch-depth: 0 + + - name: Create plugins dir + run: | + cd ./OpenSearch-Dashboards + mkdir -p plugins + + - name: Checkout OpenSearch Dashboard Security plugin + uses: actions/checkout@v2 + with: + path: OpenSearch-Dashboards/plugins/security-dashboards-plugin + ref: ${{ github.ref }} + + - name: Check OpenSearch Running + continue-on-error: true + run: curl -XGET https://localhost:9200 -u 'admin:admin' -k + + - name: Get node and yarn versions + id: versions + run: | + echo "::set-output name=node_version::$(cat ./OpenSearch-Dashboards/.node-version)" + echo "::set-output name=yarn_version::$(jq -r '.engines.yarn' ./OpenSearch-Dashboards/package.json)" + + - name: Setup node + uses: actions/setup-node@v1 + with: + node-version: ${{ steps.versions.outputs.node_version }} + registry-url: 'https://registry.npmjs.org' + + - name: Install correct yarn version for OpenSearch Dashboards + run: | + npm uninstall -g yarn + echo "Installing yarn ${{ steps.versions_step.outputs.yarn_version }}" + npm i -g yarn@${{ steps.versions.outputs.yarn_version }} + + - name: Check OpenSearch Running + continue-on-error: true + run: curl -XGET https://localhost:9200 -u 'admin:admin' -k + + - name: Bootstrap OpenSearch Dashboards + continue-on-error: false + run: | + cd ./OpenSearch-Dashboards + yarn osd bootstrap + echo 'server.host: "0.0.0.0"' >> ./config/opensearch_dashboards.yml + echo 'opensearch.hosts: ["https://localhost:9200"]' >> ./config/opensearch_dashboards.yml + echo 'opensearch.ssl.verificationMode: none' >> ./config/opensearch_dashboards.yml + echo 'opensearch.username: "kibanaserver"' >> ./config/opensearch_dashboards.yml + echo 'opensearch.password: "kibanaserver"' >> ./config/opensearch_dashboards.yml + echo 'opensearch.requestHeadersWhitelist: [ authorization,securitytenant ]' >> ./config/opensearch_dashboards.yml + echo 'opensearch_security.multitenancy.enabled: true' >> ./config/opensearch_dashboards.yml + echo 'opensearch_security.multitenancy.tenants.preferred: ["Private", "Global"]' >> ./config/opensearch_dashboards.yml + echo 'opensearch_security.readonly_mode.roles: ["kibana_read_only"]' >> ./config/opensearch_dashboards.yml + echo 'opensearch_security.cookie.secure: false' >> ./config/opensearch_dashboards.yml + echo 'opensearch_security.multitenancy.enable_aggregation_view: true' >> ./config/opensearch_dashboards.yml + yarn start --no-base-path --no-watch & + sleep 300 + + - name: Checkout + uses: actions/checkout@v2 + with: + path: ${{ env.FTR_PATH }} + repository: opensearch-project/opensearch-dashboards-functional-test + ref: 'main' + + - name: Get Cypress version + id: cypress_version + run: | + echo "::set-output name=cypress_version::$(cat ./${{ env.FTR_PATH }}/package.json | jq '.devDependencies.cypress' | tr -d '"')" + + - name: Run tests + uses: cypress-io/github-action@v2 + with: + working-directory: ${{ env.FTR_PATH }} + command: yarn cypress:run-with-security-and-aggregation-view --browser chromium --spec ${{ env.SPEC }} diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json index b9a2096c9..e75f76792 100644 --- a/opensearch_dashboards.json +++ b/opensearch_dashboards.json @@ -3,7 +3,7 @@ "version": "3.0.0.0", "opensearchDashboardsVersion": "3.0.0", "configPath": ["opensearch_security"], - "requiredPlugins": ["navigation"], + "requiredPlugins": ["navigation", "savedObjectsManagement"], "server": true, "ui": true } \ No newline at end of file diff --git a/public/apps/account/account-app.tsx b/public/apps/account/account-app.tsx index 0ceb8f5e3..590d827ea 100644 --- a/public/apps/account/account-app.tsx +++ b/public/apps/account/account-app.tsx @@ -20,7 +20,7 @@ import { AccountNavButton } from './account-nav-button'; import { fetchAccountInfoSafe } from './utils'; import { ClientConfigType } from '../../types'; import { CUSTOM_ERROR_PAGE_URI, ERROR_MISSING_ROLE_PATH } from '../../../common'; -import { selectTenant } from '../configuration/utils/tenant-utils'; +import { fetchCurrentTenant, selectTenant } from '../configuration/utils/tenant-utils'; import { getSavedTenant, getShouldShowTenantPopup, @@ -44,7 +44,16 @@ export async function setupTopNavButton(coreStart: CoreStart, config: ClientConf coreStart.http.basePath.serverBasePath + CUSTOM_ERROR_PAGE_URI + ERROR_MISSING_ROLE_PATH; } - let tenant = accountInfo.user_requested_tenant; + let tenant: string | undefined; + if (config.multitenancy.enabled) { + try { + tenant = await fetchCurrentTenant(coreStart.http); + } catch (e) { + tenant = undefined; + console.log(e); + } + } + let shouldShowTenantPopup = true; if (tenantSpecifiedInUrl() || getShouldShowTenantPopup() === false) { @@ -67,7 +76,7 @@ export async function setupTopNavButton(coreStart: CoreStart, config: ClientConf window.location.reload(); } } - } catch (e) { + } catch (e: any) { constructErrorMessageAndLog(e, `Failed to switch to ${tenant} tenant.`); } } diff --git a/public/apps/account/account-nav-button.tsx b/public/apps/account/account-nav-button.tsx index 7bd0e578b..1ca3360b1 100644 --- a/public/apps/account/account-nav-button.tsx +++ b/public/apps/account/account-nav-button.tsx @@ -61,9 +61,10 @@ export function AccountNavButton(props: { setModal(null); window.location.reload(); }} + tenant={props.tenant!} /> ), - [props.config, props.coreStart] + [props.config, props.coreStart, props.tenant] ); // Check if the tenant modal should be shown on load diff --git a/public/apps/account/tenant-switch-panel.tsx b/public/apps/account/tenant-switch-panel.tsx index 079332015..96d5b9659 100755 --- a/public/apps/account/tenant-switch-panel.tsx +++ b/public/apps/account/tenant-switch-panel.tsx @@ -48,6 +48,7 @@ interface TenantSwitchPanelProps { handleClose: () => void; handleSwitchAndClose: () => void; config: ClientConfigType; + tenant: string; } const GLOBAL_TENANT_KEY_NAME = 'global_tenant'; @@ -90,8 +91,12 @@ export function TenantSwitchPanel(props: TenantSwitchPanelProps) { const currentUserName = accountInfo.data.user_name; setUsername(currentUserName); - // @ts-ignore - const currentRawTenantName = accountInfo.data.user_requested_tenant; + let currentRawTenantName: string | undefined; + if (props.config.multitenancy.enable_aggregation_view) { + currentRawTenantName = props.tenant; + } else { + currentRawTenantName = accountInfo.data.user_requested_tenant; + } setCurrentTenant(currentRawTenantName || '', currentUserName); } catch (e) { // TODO: switch to better error display. @@ -100,7 +105,7 @@ export function TenantSwitchPanel(props: TenantSwitchPanelProps) { }; fetchData(); - }, [props.coreStart.http]); + }, [props.coreStart.http, props.tenant, props.config.multitenancy]); // Custom tenant super select related. const onCustomTenantChange = (selectedOption: EuiComboBoxOptionOption[]) => { diff --git a/public/apps/account/test/account-app.test.tsx b/public/apps/account/test/account-app.test.tsx index fa5d45ba0..52282ab72 100644 --- a/public/apps/account/test/account-app.test.tsx +++ b/public/apps/account/test/account-app.test.tsx @@ -22,7 +22,7 @@ import { getSavedTenant, } from '../../../utils/storage-utils'; import { fetchAccountInfoSafe } from '../utils'; -import { selectTenant } from '../../configuration/utils/tenant-utils'; +import { fetchCurrentTenant, selectTenant } from '../../configuration/utils/tenant-utils'; jest.mock('../../../utils/storage-utils', () => ({ getShouldShowTenantPopup: jest.fn(), @@ -36,6 +36,7 @@ jest.mock('../utils', () => ({ jest.mock('../../configuration/utils/tenant-utils', () => ({ selectTenant: jest.fn(), + fetchCurrentTenant: jest.fn(), })); describe('Account app', () => { @@ -47,6 +48,12 @@ describe('Account app', () => { }, }; + const mockConfig = { + multitenancy: { + enable_aggregation_view: true, + }, + }; + const mockAccountInfo = { data: { roles: { @@ -55,8 +62,11 @@ describe('Account app', () => { }, }; + const mockTenant = 'test1'; + beforeAll(() => { (fetchAccountInfoSafe as jest.Mock).mockResolvedValue(mockAccountInfo); + (fetchCurrentTenant as jest.Mock).mockResolvedValue(mockTenant); }); it('Should skip if auto swich if securitytenant in url', (done) => { @@ -65,7 +75,7 @@ describe('Account app', () => { delete window.location; window.location = new URL('http://www.example.com?securitytenant=abc') as any; - setupTopNavButton(mockCoreStart, {} as any); + setupTopNavButton(mockCoreStart, mockConfig as any); process.nextTick(() => { expect(setShouldShowTenantPopup).toBeCalledWith(false); @@ -77,7 +87,7 @@ describe('Account app', () => { it('Should switch to saved tenant when securitytenant not in url', (done) => { (getSavedTenant as jest.Mock).mockReturnValueOnce('tenant1'); - setupTopNavButton(mockCoreStart, {} as any); + setupTopNavButton(mockCoreStart, mockConfig as any); process.nextTick(() => { expect(getSavedTenant).toBeCalledTimes(1); @@ -92,7 +102,7 @@ describe('Account app', () => { it('Should show tenant selection popup when neither securitytenant in url nor saved tenant', (done) => { (getSavedTenant as jest.Mock).mockReturnValueOnce(null); - setupTopNavButton(mockCoreStart, {} as any); + setupTopNavButton(mockCoreStart, mockConfig as any); process.nextTick(() => { expect(getSavedTenant).toBeCalledTimes(1); diff --git a/public/apps/configuration/configuration-app.tsx b/public/apps/configuration/configuration-app.tsx index 83ae62846..a2294315d 100644 --- a/public/apps/configuration/configuration-app.tsx +++ b/public/apps/configuration/configuration-app.tsx @@ -19,12 +19,12 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { I18nProvider } from '@osd/i18n/react'; import { AppMountParameters, CoreStart } from '../../../../../src/core/public'; -import { AppPluginStartDependencies, ClientConfigType } from '../../types'; +import { SecurityPluginStartDependencies, ClientConfigType } from '../../types'; import { AppRouter } from './app-router'; export function renderApp( coreStart: CoreStart, - navigation: AppPluginStartDependencies, + navigation: SecurityPluginStartDependencies, params: AppMountParameters, config: ClientConfigType ) { diff --git a/public/apps/configuration/panels/role-edit/cluster-permission-panel.tsx b/public/apps/configuration/panels/role-edit/cluster-permission-panel.tsx index b6e1d4871..e80d0d0e1 100644 --- a/public/apps/configuration/panels/role-edit/cluster-permission-panel.tsx +++ b/public/apps/configuration/panels/role-edit/cluster-permission-panel.tsx @@ -49,6 +49,7 @@ export function ClusterPermissionPanel(props: { options={optionUniverse} selectedOptions={state} onChange={setState} + id="roles-cluster-permission-box" /> </EuiFlexItem> {/* TODO: 'Browse and select' button with a pop-up modal for selection */} diff --git a/public/apps/configuration/panels/role-edit/index-permission-panel.tsx b/public/apps/configuration/panels/role-edit/index-permission-panel.tsx index 4c0e0ae7b..75e2856e4 100644 --- a/public/apps/configuration/panels/role-edit/index-permission-panel.tsx +++ b/public/apps/configuration/panels/role-edit/index-permission-panel.tsx @@ -119,13 +119,16 @@ export function IndexPatternRow(props: { }) { return ( <FormRow headerText="Index" helpText="Specify index pattern using *"> - <EuiComboBox - noSuggestions - placeholder="Search for index name or type in index pattern" - selectedOptions={props.value} - onChange={props.onChangeHandler} - onCreateOption={props.onCreateHandler} - /> + <EuiFlexItem className={LIMIT_WIDTH_INPUT_CLASS}> + <EuiComboBox + noSuggestions + placeholder="Search for index name or type in index pattern" + selectedOptions={props.value} + onChange={props.onChangeHandler} + onCreateOption={props.onCreateHandler} + id="index-input-box" + /> + </EuiFlexItem> </FormRow> ); } @@ -150,6 +153,7 @@ export function IndexPermissionRow(props: { options={props.permisionOptionsSet} selectedOptions={props.value} onChange={props.onChangeHandler} + id="roles-index-permission-box" /> </EuiFlexItem> {/* TODO: 'Browse and select' button with a pop-up modal for selection */} diff --git a/public/apps/configuration/panels/role-edit/tenant-panel.tsx b/public/apps/configuration/panels/role-edit/tenant-panel.tsx index a2c239755..2cce9e546 100644 --- a/public/apps/configuration/panels/role-edit/tenant-panel.tsx +++ b/public/apps/configuration/panels/role-edit/tenant-panel.tsx @@ -91,6 +91,7 @@ function generateTenantPermissionPanels( onChange={onValueChangeHandler('tenantPatterns')} onCreateOption={onCreateOptionHandler('tenantPatterns')} options={permisionOptionsSet} + id="roles-tenant-permission-box" /> </EuiFlexItem> <EuiFlexItem style={{ maxWidth: '170px' }}> diff --git a/public/apps/configuration/utils/tenant-utils.tsx b/public/apps/configuration/utils/tenant-utils.tsx index 080a93f1c..8c50ffcda 100644 --- a/public/apps/configuration/utils/tenant-utils.tsx +++ b/public/apps/configuration/utils/tenant-utils.tsx @@ -15,6 +15,8 @@ import { HttpStart } from 'opensearch-dashboards/public'; import { map } from 'lodash'; +import React from 'react'; +import { i18n } from '@osd/i18n'; import { API_ENDPOINT_TENANTS, API_ENDPOINT_MULTITENANCY, @@ -36,9 +38,15 @@ import { httpDelete, httpGet, httpPost } from './request-utils'; import { getResourceUrl } from './resource-utils'; export const globalTenantName = 'global_tenant'; +export const GLOBAL_TENANT_SYMBOL = ''; +export const PRIVATE_TENANT_SYMBOL = '__user__'; +export const DEFAULT_TENANT = 'default'; +export const GLOBAL_TENANT_RENDERING_TEXT = 'Global'; +export const PRIVATE_TENANT_RENDERING_TEXT = 'Private'; + export const GLOBAL_USER_DICT: { [key: string]: string } = { Label: 'Global', - Value: '', + Value: GLOBAL_TENANT_SYMBOL, Description: 'Everyone can see it', }; @@ -62,10 +70,10 @@ export function transformTenantData( ): Tenant[] { // @ts-ignore const tenantList: Tenant[] = map<Tenant, Tenant>(rawTenantData, (v: Tenant, k?: string) => ({ - tenant: k === globalTenantName ? GLOBAL_USER_DICT.Label : k || '', + tenant: k === globalTenantName ? GLOBAL_USER_DICT.Label : k || GLOBAL_TENANT_SYMBOL, reserved: v.reserved, description: k === globalTenantName ? GLOBAL_USER_DICT.Description : v.description, - tenantValue: k === globalTenantName ? GLOBAL_USER_DICT.Value : k || '', + tenantValue: k === globalTenantName ? GLOBAL_USER_DICT.Value : k || GLOBAL_TENANT_SYMBOL, })); if (isPrivateEnabled) { // Insert Private Tenant in List @@ -170,3 +178,37 @@ export function transformRoleTenantPermissions( permissionType: getTenantPermissionType(tenantPermission.allowed_actions), })); } + +export function isPrivateTenant(selectedTenant: string | null) { + return selectedTenant !== null && selectedTenant === PRIVATE_TENANT_SYMBOL; +} + +export function isRenderingPrivateTenant(selectedTenant: string | null) { + return selectedTenant !== null && selectedTenant?.startsWith(PRIVATE_TENANT_SYMBOL); +} + +export function isGlobalTenant(selectedTenant: string | null) { + return selectedTenant !== null && selectedTenant === GLOBAL_TENANT_SYMBOL; +} + +export const tenantColumn = { + id: 'tenant_column', + euiColumn: { + field: 'namespaces', + name: <div>Tenant</div>, + dataType: 'string', + render: (value: any[][]) => { + let text = value.flat()[0]; + if (isGlobalTenant(text)) { + text = GLOBAL_TENANT_RENDERING_TEXT; + } else if (isRenderingPrivateTenant(text)) { + text = PRIVATE_TENANT_RENDERING_TEXT; + } + text = i18n.translate('savedObjectsManagement.objectsTable.table.columnTenantName', { + defaultMessage: text, + }); + return <div>{text}</div>; + }, + }, + loadData: () => {}, +}; diff --git a/public/apps/types.ts b/public/apps/types.ts index 0ca39e1b0..3f5c870b0 100644 --- a/public/apps/types.ts +++ b/public/apps/types.ts @@ -14,11 +14,11 @@ */ import { AppMountParameters, CoreStart } from '../../../../src/core/public'; -import { AppPluginStartDependencies, ClientConfigType } from '../types'; +import { SecurityPluginStartDependencies, ClientConfigType } from '../types'; export interface AppDependencies { coreStart: CoreStart; - navigation: AppPluginStartDependencies; + navigation: SecurityPluginStartDependencies; params: AppMountParameters; config: ClientConfigType; } diff --git a/public/plugin.ts b/public/plugin.ts index 1ecf452fb..2adb33a83 100644 --- a/public/plugin.ts +++ b/public/plugin.ts @@ -14,6 +14,7 @@ */ import { BehaviorSubject } from 'rxjs'; +import { SavedObjectsManagementColumn } from 'src/plugins/saved_objects_management/public'; import { AppMountParameters, AppStatus, @@ -37,13 +38,15 @@ import { excludeFromDisabledTransportCategories, } from './apps/configuration/panels/audit-logging/constants'; import { - AppPluginStartDependencies, + SecurityPluginStartDependencies, ClientConfigType, SecurityPluginSetup, SecurityPluginStart, + SecurityPluginSetupDependencies, } from './types'; import { addTenantToShareURL } from './services/shared-link'; import { interceptError } from './utils/logout-utils'; +import { tenantColumn } from './apps/configuration/utils/tenant-utils'; async function hasApiPermission(core: CoreSetup): Promise<boolean | undefined> { try { @@ -62,12 +65,24 @@ const APP_ID_DASHBOARDS = 'dashboards'; // OpenSearchDashboards app is for legacy url migration const APP_ID_OPENSEARCH_DASHBOARDS = 'kibana'; const APP_LIST_FOR_READONLY_ROLE = [APP_ID_HOME, APP_ID_DASHBOARDS, APP_ID_OPENSEARCH_DASHBOARDS]; - -export class SecurityPlugin implements Plugin<SecurityPluginSetup, SecurityPluginStart> { +const GLOBAL_TENANT_RENDERING_TEXT = 'Global'; +const PRIVATE_TENANT_RENDERING_TEXT = 'Private'; + +export class SecurityPlugin + implements + Plugin< + SecurityPluginSetup, + SecurityPluginStart, + SecurityPluginSetupDependencies, + SecurityPluginStartDependencies + > { // @ts-ignore : initializerContext not used constructor(private readonly initializerContext: PluginInitializerContext) {} - public async setup(core: CoreSetup): Promise<SecurityPluginSetup> { + public async setup( + core: CoreSetup, + deps: SecurityPluginSetupDependencies + ): Promise<SecurityPluginSetup> { const apiPermission = await hasApiPermission(core); const config = this.initializerContext.config.get<ClientConfigType>(); @@ -93,7 +108,7 @@ export class SecurityPlugin implements Plugin<SecurityPluginSetup, SecurityPlugi excludeFromDisabledTransportCategories(config.disabledTransportCategories.exclude); excludeFromDisabledRestCategories(config.disabledRestCategories.exclude); - return renderApp(coreStart, depsStart as AppPluginStartDependencies, params, config); + return renderApp(coreStart, depsStart as SecurityPluginStartDependencies, params, config); }, category: { id: 'opensearch', @@ -138,11 +153,17 @@ export class SecurityPlugin implements Plugin<SecurityPluginSetup, SecurityPlugi }) ); + if (config.multitenancy.enabled && config.multitenancy.enable_aggregation_view) { + deps.savedObjectsManagement.columns.register( + (tenantColumn as unknown) as SavedObjectsManagementColumn<string> + ); + } + // Return methods that should be available to other plugins return {}; } - public start(core: CoreStart): SecurityPluginStart { + public start(core: CoreStart, deps: SecurityPluginStartDependencies): SecurityPluginStart { const config = this.initializerContext.config.get<ClientConfigType>(); setupTopNavButton(core, config); diff --git a/public/types.ts b/public/types.ts index 9e63ca632..8dd2ac2c4 100644 --- a/public/types.ts +++ b/public/types.ts @@ -14,14 +14,23 @@ */ import { NavigationPublicPluginStart } from '../../../src/plugins/navigation/public'; +import { + SavedObjectsManagementPluginSetup, + SavedObjectsManagementPluginStart, +} from '../../../src/plugins/saved_objects_management/public'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface SecurityPluginSetup {} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface SecurityPluginStart {} -export interface AppPluginStartDependencies { +export interface SecurityPluginSetupDependencies { + savedObjectsManagement: SavedObjectsManagementPluginSetup; +} + +export interface SecurityPluginStartDependencies { navigation: NavigationPublicPluginStart; + savedObjectsManagement: SavedObjectsManagementPluginStart; } export interface AuthInfo { @@ -49,6 +58,7 @@ export interface ClientConfigType { backend_configurable: boolean; }; multitenancy: { + enable_aggregation_view: boolean; enabled: boolean; tenants: { enable_private: boolean; diff --git a/server/auth/types/authentication_type.ts b/server/auth/types/authentication_type.ts index 2b1cfdf75..6d8274ce2 100755 --- a/server/auth/types/authentication_type.ts +++ b/server/auth/types/authentication_type.ts @@ -35,6 +35,7 @@ import { isValidTenant, } from '../../multitenancy/tenant_resolver'; import { UnauthenticatedError } from '../../errors'; +import { GLOBAL_TENANT_SYMBOL } from '../../../public/apps/configuration/utils/tenant-utils'; export interface IAuthenticationType { type: string; @@ -50,6 +51,25 @@ export type IAuthHandlerConstructor = new ( logger: Logger ) => IAuthenticationType; +export interface OpenSearchAuthInfo { + user: string; + user_name: string; + user_requested_tenant: string; + remote_address: string; + backend_roles: string[]; + custom_attribute_names: string[]; + roles: string[]; + tenants: Record<string, boolean>; + principal: string | null; + peer_certificates: string | null; + sso_logout_url: string | null; +} + +export interface OpenSearchDashboardsAuthState { + authInfo?: OpenSearchAuthInfo; + selectedTenant?: string; +} + export abstract class AuthenticationType implements IAuthenticationType { protected static readonly ROUTES_TO_IGNORE: string[] = [ '/api/core/capabilities', // FIXME: need to figureout how to bypass this API call @@ -72,6 +92,7 @@ export abstract class AuthenticationType implements IAuthenticationType { ) { this.securityClient = new SecurityClient(esClient); this.type = ''; + this.config = config; } public authHandler: AuthenticationHandler = async (request, response, toolkit) => { @@ -80,6 +101,8 @@ export abstract class AuthenticationType implements IAuthenticationType { return toolkit.authenticated(); } + const authState: OpenSearchDashboardsAuthState = {}; + // if browser request, auth logic is: // 1. check if request includes auth header or paramter(e.g. jwt in url params) is present, if so, authenticate with auth header. // 2. if auth header not present, check if auth cookie is present, if no cookie, send to authentication workflow @@ -104,7 +127,7 @@ export abstract class AuthenticationType implements IAuthenticationType { } this.sessionStorageFactory.asScoped(request).set(cookie); - } catch (error) { + } catch (error: any) { return response.unauthorized({ body: error.message, }); @@ -113,7 +136,7 @@ export abstract class AuthenticationType implements IAuthenticationType { // no auth header in request, try cookie try { cookie = await this.sessionStorageFactory.asScoped(request).get(); - } catch (error) { + } catch (error: any) { this.logger.error(`Error parsing cookie: ${error.message}`); cookie = undefined; } @@ -157,8 +180,15 @@ export abstract class AuthenticationType implements IAuthenticationType { 'No available tenant for current user, please reach out to your system administrator', }); } + authState.selectedTenant = tenant; + // set tenant in header - Object.assign(authHeaders, { securitytenant: tenant }); + if (this.config.multitenancy.enabled && this.config.multitenancy.enable_aggregation_view) { + // Store all saved objects in a single kibana index. + Object.assign(authHeaders, { securitytenant: GLOBAL_TENANT_SYMBOL }); + } else { + Object.assign(authHeaders, { securitytenant: tenant }); + } // set tenant to cookie if (tenant !== cookie!.tenant) { @@ -177,9 +207,14 @@ export abstract class AuthenticationType implements IAuthenticationType { throw error; } } + if (!authInfo) { + authInfo = await this.securityClient.authinfo(request, authHeaders); + } + authState.authInfo = authInfo; return toolkit.authenticated({ requestHeaders: authHeaders, + state: authState, }); }; @@ -209,7 +244,7 @@ export abstract class AuthenticationType implements IAuthenticationType { if (!authInfo) { try { authInfo = await this.securityClient.authinfo(request, authHeader); - } catch (error) { + } catch (error: any) { throw new UnauthenticatedError(error); } } diff --git a/server/index.ts b/server/index.ts index bf1a2699d..99adb015b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -104,6 +104,7 @@ export const configSchema = schema.object({ show_roles: schema.boolean({ defaultValue: false }), enable_filter: schema.boolean({ defaultValue: false }), debug: schema.boolean({ defaultValue: false }), + enable_aggregation_view: schema.boolean({ defaultValue: false }), tenants: schema.object({ enable_private: schema.boolean({ defaultValue: true }), enable_global: schema.boolean({ defaultValue: true }), diff --git a/server/multitenancy/tenant_resolver.ts b/server/multitenancy/tenant_resolver.ts index f99ce1d0c..281ab4cf6 100755 --- a/server/multitenancy/tenant_resolver.ts +++ b/server/multitenancy/tenant_resolver.ts @@ -17,9 +17,10 @@ import { isEmpty, findKey, cloneDeep } from 'lodash'; import { OpenSearchDashboardsRequest } from '../../../../src/core/server'; import { SecuritySessionCookie } from '../session/security_cookie'; import { SecurityPluginConfigType } from '..'; - -const PRIVATE_TENANT_SYMBOL: string = '__user__'; -const GLOBAL_TENANT_SYMBOL: string = ''; +import { + GLOBAL_TENANT_SYMBOL, + PRIVATE_TENANT_SYMBOL, +} from '../../public/apps/configuration/utils/tenant-utils'; export const PRIVATE_TENANTS: string[] = [PRIVATE_TENANT_SYMBOL, 'private']; export const GLOBAL_TENANTS: string[] = ['global', GLOBAL_TENANT_SYMBOL]; diff --git a/server/plugin.ts b/server/plugin.ts index 8c04eb275..b7c9e0ce8 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -38,11 +38,15 @@ import { ISavedObjectTypeRegistry, } from '../../../src/core/server/saved_objects'; import { setupIndexTemplate, migrateTenantIndices } from './multitenancy/tenant_index'; -import { IAuthenticationType } from './auth/types/authentication_type'; +import { + IAuthenticationType, + OpenSearchDashboardsAuthState, +} from './auth/types/authentication_type'; import { getAuthenticationHandler } from './auth/auth_handler_factory'; import { setupMultitenantRoutes } from './multitenancy/routes'; import { defineAuthTypeRoutes } from './routes/auth_type_routes'; import { createMigrationOpenSearchClient } from '../../../src/core/server/saved_objects/migrations/core'; +import { SecuritySavedObjectsClientWrapper } from './saved_objects/saved_objects_wrapper'; export interface SecurityPluginRequestContext { logger: Logger; @@ -73,8 +77,11 @@ export class SecurityPlugin implements Plugin<SecurityPluginSetup, SecurityPlugi // @ts-ignore: property not initialzied in constructor private securityClient: SecurityClient; + private savedObjectClientWrapper: SecuritySavedObjectsClientWrapper; + constructor(private readonly initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); + this.savedObjectClientWrapper = new SecuritySavedObjectsClientWrapper(); } public async setup(core: CoreSetup) { @@ -126,6 +133,14 @@ export class SecurityPlugin implements Plugin<SecurityPluginSetup, SecurityPlugi setupMultitenantRoutes(router, securitySessionStorageFactory, this.securityClient); } + if (config.multitenancy.enabled && config.multitenancy.enable_aggregation_view) { + core.savedObjects.addClientWrapper( + 2, + 'security-saved-object-client-wrapper', + this.savedObjectClientWrapper.wrapperFactory + ); + } + return { config$, securityConfigClient: esClient, @@ -135,8 +150,13 @@ export class SecurityPlugin implements Plugin<SecurityPluginSetup, SecurityPlugi // TODO: add more logs public async start(core: CoreStart) { this.logger.debug('opendistro_security: Started'); + const config$ = this.initializerContext.config.create<SecurityPluginConfigType>(); const config = await config$.pipe(first()).toPromise(); + + this.savedObjectClientWrapper.httpStart = core.http; + this.savedObjectClientWrapper.config = config; + if (config.multitenancy?.enabled) { const globalConfig$: Observable<SharedGlobalConfig> = this.initializerContext.config.legacy .globalConfig$; @@ -161,6 +181,7 @@ export class SecurityPlugin implements Plugin<SecurityPluginSetup, SecurityPlugi } return { + http: core.http, es: core.opensearch.legacy, }; } diff --git a/server/saved_objects/saved_objects_wrapper.ts b/server/saved_objects/saved_objects_wrapper.ts new file mode 100644 index 000000000..0cf767ebe --- /dev/null +++ b/server/saved_objects/saved_objects_wrapper.ts @@ -0,0 +1,219 @@ +/* + * 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 _ from 'lodash'; +import { + HttpServiceStart, + SavedObject, + SavedObjectsBaseOptions, + SavedObjectsBulkCreateObject, + SavedObjectsBulkGetObject, + SavedObjectsBulkResponse, + SavedObjectsBulkUpdateObject, + SavedObjectsBulkUpdateOptions, + SavedObjectsBulkUpdateResponse, + SavedObjectsCheckConflictsObject, + SavedObjectsCheckConflictsResponse, + SavedObjectsClientWrapperFactory, + SavedObjectsCreateOptions, + SavedObjectsDeleteOptions, + SavedObjectsFindOptions, + SavedObjectsFindResponse, + SavedObjectsUpdateOptions, + SavedObjectsUpdateResponse, +} from 'opensearch-dashboards/server'; +import { Config } from 'packages/osd-config/target'; +import { SecurityPluginConfigType } from '..'; +import { + DEFAULT_TENANT, + globalTenantName, + GLOBAL_TENANT_SYMBOL, + isPrivateTenant, + PRIVATE_TENANT_SYMBOL, +} from '../../public/apps/configuration/utils/tenant-utils'; +import { OpenSearchDashboardsAuthState } from '../auth/types/authentication_type'; + +export class SecuritySavedObjectsClientWrapper { + public httpStart?: HttpServiceStart; + public config?: SecurityPluginConfigType; + + constructor() {} + + public wrapperFactory: SavedObjectsClientWrapperFactory = (wrapperOptions) => { + const state: OpenSearchDashboardsAuthState = + (this.httpStart!.auth.get(wrapperOptions.request).state as OpenSearchDashboardsAuthState) || + {}; + + const selectedTenant = state.selectedTenant; + const username = state.authInfo?.user_name; + const isGlobalEnabled = this.config!.multitenancy.tenants.enable_global; + const isPrivateEnabled = this.config!.multitenancy.tenants.enable_private; + + let namespaceValue = selectedTenant; + + const createWithNamespace = async <T = unknown>( + type: string, + attributes: T, + options?: SavedObjectsCreateOptions + ) => { + namespaceValue = this.getNamespaceValue(selectedTenant, isPrivateEnabled, username); + _.assign(options, { namespace: [namespaceValue] }); + return await wrapperOptions.client.create(type, attributes, options); + }; + + const bulkGetWithNamespace = async <T = unknown>( + objects: SavedObjectsBulkGetObject[] = [], + options: SavedObjectsBaseOptions = {} + ): Promise<SavedObjectsBulkResponse<T>> => { + namespaceValue = this.getNamespaceValue(selectedTenant, isPrivateEnabled, username); + _.assign(options, { namespace: [namespaceValue] }); + return await wrapperOptions.client.bulkGet(objects, options); + }; + + const findWithNamespace = async <T = unknown>( + options: SavedObjectsFindOptions + ): Promise<SavedObjectsFindResponse<T>> => { + const tenants = state.authInfo?.tenants; + const availableTenantNames = Object.keys(tenants!); + availableTenantNames.push(DEFAULT_TENANT); // The value of namespace is "default" if saved objects are created when opensearch_security.multitenancy.enable_aggregation_view is set to false. So adding it to find. + if (isGlobalEnabled) { + availableTenantNames.push(GLOBAL_TENANT_SYMBOL); + } + if (isPrivateEnabled) { + availableTenantNames.push(PRIVATE_TENANT_SYMBOL + username); + } + if (availableTenantNames.includes(globalTenantName)) { + let index = availableTenantNames.indexOf(globalTenantName); + if (index > -1) { + availableTenantNames.splice(index, 1); + } + index = availableTenantNames.indexOf(username!); + if (index > -1) { + availableTenantNames.splice(index, 1); + } + } + const typeToNamespacesMap: any = {}; + if (isPrivateTenant(selectedTenant!)) { + namespaceValue = selectedTenant! + username; + } + const searchTypes = Array.isArray(options.type) ? options.type : [options.type]; + searchTypes.forEach((t) => { + if ('namespaces' in options) { + typeToNamespacesMap[t] = options.namespaces; + } else { + typeToNamespacesMap[t] = availableTenantNames; + } + }); + if ('config' in typeToNamespacesMap) { + typeToNamespacesMap.config = [namespaceValue]; + } + options.typeToNamespacesMap = new Map(Object.entries(typeToNamespacesMap)); + options.type = ''; + options.namespaces = []; + + return await wrapperOptions.client.find(options); + }; + + const getWithNamespace = async <T = unknown>( + type: string, + id: string, + options: SavedObjectsBaseOptions = {} + ): Promise<SavedObject<T>> => { + namespaceValue = this.getNamespaceValue(selectedTenant, isPrivateEnabled, username); + _.assign(options, { namespace: [namespaceValue] }); + return await wrapperOptions.client.get(type, id, options); + }; + + const updateWithNamespace = async <T = unknown>( + type: string, + id: string, + attributes: Partial<T>, + options: SavedObjectsUpdateOptions = {} + ): Promise<SavedObjectsUpdateResponse<T>> => { + namespaceValue = this.getNamespaceValue(selectedTenant, isPrivateEnabled, username); + _.assign(options, { namespace: [namespaceValue] }); + return await wrapperOptions.client.update(type, id, attributes, options); + }; + + const bulkCreateWithNamespace = async <T = unknown>( + objects: Array<SavedObjectsBulkCreateObject<T>>, + options?: SavedObjectsCreateOptions + ): Promise<SavedObjectsBulkResponse<T>> => { + namespaceValue = this.getNamespaceValue(selectedTenant, isPrivateEnabled, username); + _.assign(options, { namespace: [namespaceValue] }); + return await wrapperOptions.client.bulkCreate(objects, options); + }; + + const bulkUpdateWithNamespace = async <T = unknown>( + objects: Array<SavedObjectsBulkUpdateObject<T>>, + options?: SavedObjectsBulkUpdateOptions + ): Promise<SavedObjectsBulkUpdateResponse<T>> => { + namespaceValue = this.getNamespaceValue(selectedTenant, isPrivateEnabled, username); + _.assign(options, { namespace: [namespaceValue] }); + return await wrapperOptions.client.bulkUpdate(objects, options); + }; + + const deleteWithNamespace = async ( + type: string, + id: string, + options: SavedObjectsDeleteOptions = {} + ) => { + namespaceValue = this.getNamespaceValue(selectedTenant, isPrivateEnabled, username); + _.assign(options, { namespace: [namespaceValue] }); + return await wrapperOptions.client.delete(type, id, options); + }; + + const checkConflictsWithNamespace = async ( + objects: SavedObjectsCheckConflictsObject[] = [], + options: SavedObjectsBaseOptions = {} + ): Promise<SavedObjectsCheckConflictsResponse> => { + namespaceValue = this.getNamespaceValue(selectedTenant, isPrivateEnabled, username); + _.assign(options, { namespace: [namespaceValue] }); + return await wrapperOptions.client.checkConflicts(objects, options); + }; + + return { + ...wrapperOptions.client, + get: getWithNamespace, + update: updateWithNamespace, + bulkCreate: bulkCreateWithNamespace, + bulkGet: bulkGetWithNamespace, + bulkUpdate: bulkUpdateWithNamespace, + create: createWithNamespace, + delete: deleteWithNamespace, + errors: wrapperOptions.client.errors, + checkConflicts: checkConflictsWithNamespace, + addToNamespaces: wrapperOptions.client.addToNamespaces, + find: findWithNamespace, + deleteFromNamespaces: wrapperOptions.client.deleteFromNamespaces, + }; + }; + + private isAPrivateTenant(selectedTenant: string | undefined, isPrivateEnabled: boolean) { + return selectedTenant !== undefined && isPrivateEnabled && isPrivateTenant(selectedTenant); + } + + private getNamespaceValue( + selectedTenant: string | undefined, + isPrivateEnabled: boolean, + username: string | undefined + ) { + let namespaceValue = selectedTenant; + if (this.isAPrivateTenant(selectedTenant, isPrivateEnabled)) { + namespaceValue = selectedTenant! + username; + } + return namespaceValue; + } +}