Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show controls as read only based on tenant permissions #1472

Merged
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
87d40fa
Introduce read-only tenant mode logic
kajetan-nobel Jul 18, 2023
c38d4cc
Fix lint
kajetan-nobel Jul 18, 2023
54a6364
Merge branch 'main' into read-only-tenant-logic-poc
stephen-crawford Jul 27, 2023
da1720b
fix: typo in error message
kajetan-nobel Jul 28, 2023
bad9760
Merge remote-tracking branch 'origin/main' into read-only-tenant-logi…
kajetan-nobel Aug 4, 2023
bb2e4e5
Merge remote-tracking branch 'origin/main' into read-only-tenant-logi…
kajetan-nobel Aug 7, 2023
58a28d4
Merge remote-tracking branch 'origin/main' into read-only-tenant-logi…
kajetan-nobel Aug 16, 2023
feb6759
Merge remote-tracking branch 'origin/main' into read-only-tenant-logi…
kajetan-nobel Sep 5, 2023
8f97130
feat: move readonly tenant logic to a service
kajetan-nobel Sep 13, 2023
982ce6c
Merge remote-tracking branch 'origin/main' into read-only-tenant-logi…
kajetan-nobel Sep 13, 2023
31899ce
feat: extend cores readonly service
kajetan-nobel Sep 13, 2023
116dc99
Merge branch 'main' into read-only-tenant-logic-poc
kajetan-nobel Oct 10, 2023
9484a09
Merge branch 'main' into read-only-tenant-logic-poc
DarshitChanpura Oct 20, 2023
10c5784
feat: adds tests for readonly_service
kajetan-nobel Oct 25, 2023
8ec5413
Merge branch 'main' into read-only-tenant-logic-poc
kajetan-nobel Oct 25, 2023
798ca64
Apply suggestions from code review
kajetan-nobel Oct 30, 2023
e70efb5
feat: improve isAnonymousPage, add error log in isReadonly
kajetan-nobel Oct 31, 2023
69c6440
Merge branch 'main' into read-only-tenant-logic-poc
peternied Nov 7, 2023
569195d
Merge branch 'main' into HEAD
kajetan-nobel Nov 14, 2023
b52248e
Merge branch 'main' into read-only-tenant-logic-poc
stephen-crawford Nov 16, 2023
83bc08b
Merge branch 'main' into read-only-tenant-logic-poc
DarshitChanpura Nov 17, 2023
d902f1f
Merge branch 'main' into read-only-tenant-logic-poc
DarshitChanpura Nov 17, 2023
65646f5
Merge remote-tracking branch 'jakubp-eliatra/read-only-tenant-logic-p…
kajetan-nobel Nov 22, 2023
b5b9872
feat: add request.route.options.authRequired check
kajetan-nobel Nov 23, 2023
063664b
2feat: ReadonlyService is aware of disabled multitenancy through dash…
kajetan-nobel Nov 23, 2023
73e00c2
Merge branch 'main' into read-only-tenant-logic-poc
kajetan-nobel Nov 23, 2023
e1d41d4
refactor: remove unused imports, remove abstract from class due to du…
kajetan-nobel Nov 27, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions server/auth/types/authentication_type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ export interface IAuthenticationType {
type: string;
authHandler: AuthenticationHandler;
init: () => Promise<void>;
requestIncludesAuthInfo(request: OpenSearchDashboardsRequest): boolean;
cwperks marked this conversation as resolved.
Show resolved Hide resolved
buildAuthHeaderFromCookie(
cookie: SecuritySessionCookie,
request: OpenSearchDashboardsRequest
): any;
}

export type IAuthHandlerConstructor = new (
Expand Down
18 changes: 13 additions & 5 deletions server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@

import { first } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { ResponseObject } from '@hapi/hapi';
import {
PluginInitializerContext,
CoreSetup,
Expand All @@ -39,16 +38,14 @@ import {
ISavedObjectTypeRegistry,
} from '../../../src/core/server/saved_objects';
import { setupIndexTemplate, migrateTenantIndices } from './multitenancy/tenant_index';
import {
IAuthenticationType,
OpenSearchDashboardsAuthState,
} from './auth/types/authentication_type';
import { IAuthenticationType } 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';
import { addTenantParameterToResolvedShortLink } from './multitenancy/tenant_resolver';
import { ReadonlyService } from './readonly/readonly_service';

export interface SecurityPluginRequestContext {
logger: Logger;
Expand Down Expand Up @@ -138,6 +135,7 @@ export class SecurityPlugin implements Plugin<SecurityPluginSetup, SecurityPlugi
// Register server side APIs
defineRoutes(router);
defineAuthTypeRoutes(router, config);

// set up multi-tenent routes
if (config.multitenancy?.enabled) {
setupMultitenantRoutes(router, securitySessionStorageFactory, this.securityClient);
Expand All @@ -151,6 +149,16 @@ export class SecurityPlugin implements Plugin<SecurityPluginSetup, SecurityPlugi
);
}

const service = new ReadonlyService(
this.logger,
this.securityClient,
auth,
securitySessionStorageFactory,
config
);

core.security.registerReadonlyService(service);

return {
config$,
securityConfigClient: esClient,
Expand Down
212 changes: 212 additions & 0 deletions server/readonly/readonly_service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
/*
* 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 { loggerMock } from '@osd/logging/target/mocks';
import { httpServerMock, sessionStorageMock } from '../../../../src/core/server/mocks';
import { ILegacyClusterClient } from '../../../../src/core/server/opensearch/legacy/cluster_client';
import { PRIVATE_TENANT_SYMBOL } from '../../common/index';
import { OpenSearchAuthInfo } from '../auth/types/authentication_type';
import { BasicAuthentication } from '../auth/types/index';
import { SecurityClient } from '../backend/opensearch_security_client';
import { SecurityPluginConfigType } from '../index';
import { SecuritySessionCookie } from '../session/security_cookie';
import { ReadonlyService } from './readonly_service';

jest.mock('../auth/types/basic/basic_auth');

const mockCookie = (data: Partial<SecuritySessionCookie> = {}): SecuritySessionCookie =>
Object.assign(
{
username: 'test',
credentials: {
authHeaderValue: 'Basic cmVhZG9ubHk6Z2FzZGN4ejRRIQ==',
},
authType: 'basicauth',
isAnonymousAuth: false,
tenant: '__user__',
},
data
);

const mockEsClient = (): jest.Mocked<ILegacyClusterClient> => {
return {
callAsInternalUser: jest.fn(),
asScoped: jest.fn(),
};
};

const mockAuthInfo = (data: Partial<OpenSearchAuthInfo> = {}): OpenSearchAuthInfo =>
Object.assign(
{
user: '',
user_name: 'admin',
user_requested_tenant: PRIVATE_TENANT_SYMBOL,
remote_address: '127.0.0.1',
backend_roles: ['admin'],
custom_attribute_names: [],
roles: ['own_index', 'all_access'],
tenants: {
admin_tenant: true,
admin: true,
},
principal: null,
peer_certificates: '0',
sso_logout_url: null,
},
data
);

const mockDashboardsInfo = (data = {}) =>
Object.assign(
{
user_name: 'admin',
multitenancy_enabled: true,
},
data
);

const getService = (
cookie: SecuritySessionCookie = mockCookie(),
authInfo: OpenSearchAuthInfo = mockAuthInfo(),
dashboardsInfo = mockDashboardsInfo()
) => {
const logger = loggerMock.create();

const securityClient = new SecurityClient(mockEsClient());
securityClient.authinfo = jest.fn().mockReturnValue(authInfo);
securityClient.dashboardsinfo = jest.fn().mockReturnValue(dashboardsInfo);

// @ts-ignore mock auth
const auth = new BasicAuthentication();
auth.requestIncludesAuthInfo = jest.fn().mockReturnValue(true);

const securitySessionStorageFactory = sessionStorageMock.createFactory<SecuritySessionCookie>();
securitySessionStorageFactory.asScoped = jest.fn().mockReturnValue({
get: jest.fn().mockResolvedValue(cookie),
});

const config = {
multitenancy: {
enabled: true,
},
} as SecurityPluginConfigType;

return new ReadonlyService(logger, securityClient, auth, securitySessionStorageFactory, config);
};

describe('checks isAnonymousPage', () => {
const service = getService();

it.each([
// Missing referer header
[
{
path: '/api/core/capabilities',
headers: {},
auth: {
isAuthenticated: false,
mode: 'optional',
},
},
false,
],
// Referer with not anynoumous page
[
{
headers: {
referer: 'https://localhost/app/management/opensearch-dashboards/indexPatterns',
},
},
false,
],
// Referer with anynoumous page
[
{
path: '/app/login',
headers: {
referer: 'https://localhost/app/login',
},
routeAuthRequired: false,
},
true,
],
])('%j returns result %s', (requestData, expectedResult) => {
const request = httpServerMock.createOpenSearchDashboardsRequest(requestData);
expect(service.isAnonymousPage(request)).toEqual(expectedResult);
});
});

describe('checks isReadOnlyTenant', () => {
const service = getService();

it.each([
// returns false with private global tenant
[mockAuthInfo({ user_requested_tenant: PRIVATE_TENANT_SYMBOL }), false],
// returns false when has requested tenant but it's read and write
[
mockAuthInfo({
user_requested_tenant: 'readonly_tenant',
tenants: {
readonly_tenant: true,
},
}),
false,
],
// returns true when has requested tenant and it's read only
[
mockAuthInfo({
user_requested_tenant: 'readonly_tenant',
tenants: {
readonly_tenant: false,
},
}),
true,
],
])('%j returns result %s', (authInfo, expectedResult) => {
expect(service.isReadOnlyTenant(authInfo)).toBe(expectedResult);
});
});

describe('checks isReadonly', () => {
it('calls isAnonymousPage', async () => {
const service = getService();
service.isAnonymousPage = jest.fn(() => true);
await service.isReadonly(httpServerMock.createOpenSearchDashboardsRequest());
expect(service.isAnonymousPage).toBeCalled();
});
it('calls isReadOnlyTenant with correct authinfo', async () => {
const cookie = mockCookie({ tenant: 'readonly_tenant' });
const authInfo = mockAuthInfo({
user_requested_tenant: 'readonly_tenant',
tenants: {
readonly_tenant: false,
},
});

const service = getService(cookie, authInfo);
service.isAnonymousPage = jest.fn(() => false);

const result = await service.isReadonly(httpServerMock.createOpenSearchDashboardsRequest());
expect(result).toBeTruthy();
});
it('calls dashboardInfo and checks if multitenancy is enabled', async () => {
const dashboardsInfo = mockDashboardsInfo({ multitenancy_enabled: false });
const service = getService(mockCookie(), mockAuthInfo(), dashboardsInfo);
service.isAnonymousPage = jest.fn(() => false);

const result = await service.isReadonly(httpServerMock.createOpenSearchDashboardsRequest());
expect(result).toBeFalsy();
});
});
121 changes: 121 additions & 0 deletions server/readonly/readonly_service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* 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 { URL } from 'url';
import {
Logger,
OpenSearchDashboardsRequest,
SessionStorageFactory,
} from '../../../../src/core/server';
import {
globalTenantName,
isPrivateTenant,
LOGIN_PAGE_URI,
CUSTOM_ERROR_PAGE_URI,
} from '../../common';
import { SecurityClient } from '../backend/opensearch_security_client';
import { IAuthenticationType, OpenSearchAuthInfo } from '../auth/types/authentication_type';
import { SecuritySessionCookie } from '../session/security_cookie';
import { SecurityPluginConfigType } from '../index';
import { ReadonlyService as BaseReadonlyService } from '../../../../src/core/server/security/readonly_service';
import { getDashboardsInfoSafe } from '../../public/utils/dashboards-info-utils';
import { mult } from '../../../../src/plugins/expressions/common/test_helpers/expression_functions/mult';

export class ReadonlyService extends BaseReadonlyService {
protected static readonly ROUTES_TO_IGNORE: string[] = [LOGIN_PAGE_URI, CUSTOM_ERROR_PAGE_URI];

private readonly logger: Logger;
private readonly securityClient: SecurityClient;
private readonly auth: IAuthenticationType;
private readonly securitySessionStorageFactory: SessionStorageFactory<SecuritySessionCookie>;
private readonly config: SecurityPluginConfigType;

constructor(
logger: Logger,
securityClient: SecurityClient,
auth: IAuthenticationType,
securitySessionStorageFactory: SessionStorageFactory<SecuritySessionCookie>,
config: SecurityPluginConfigType
) {
super();
this.logger = logger;
this.securityClient = securityClient;
this.auth = auth;
this.securitySessionStorageFactory = securitySessionStorageFactory;
this.config = config;
}

isAnonymousPage(request: OpenSearchDashboardsRequest) {
peternied marked this conversation as resolved.
Show resolved Hide resolved
if (typeof request.route.options.authRequired === 'boolean') {
return !request.route.options.authRequired;
}

if (!request.headers || !request.headers.referer) {
return false;
}

const url = new URL(request.headers.referer as string);
return ReadonlyService.ROUTES_TO_IGNORE.some((path) => url.pathname?.includes(path));
}

isReadOnlyTenant(authInfo: OpenSearchAuthInfo): boolean {
const currentTenant = authInfo.user_requested_tenant || globalTenantName;
DarshitChanpura marked this conversation as resolved.
Show resolved Hide resolved

// private tenants are isolated to individual users that always have read/write permissions
if (isPrivateTenant(currentTenant)) {
return false;
}

const readWriteAccess = authInfo.tenants[currentTenant];
return !readWriteAccess;
}

async isReadonly(request: OpenSearchDashboardsRequest): Promise<boolean> {
if (!this.config?.multitenancy.enabled) {
return false;
}

// omit for anonymous pages to avoid authentication errors
if (this.isAnonymousPage(request)) {
return false;
}

try {
const cookie = await this.securitySessionStorageFactory.asScoped(request).get();
let headers = request.headers;

if (!this.auth.requestIncludesAuthInfo(request) && cookie) {
headers = this.auth.buildAuthHeaderFromCookie(cookie, request);
}

const dashboardsInfo = await this.securityClient.dashboardsinfo(request, headers);

if (!dashboardsInfo.multitenancy_enabled) {
return false;
}

const authInfo = await this.securityClient.authinfo(request, headers);

if (!authInfo.user_requested_tenant && cookie) {
authInfo.user_requested_tenant = cookie.tenant;
}
peternied marked this conversation as resolved.
Show resolved Hide resolved

return authInfo && this.isReadOnlyTenant(authInfo);
} catch (error: any) {
this.logger.error(`Failed to resolve if it's a readonly tenant: ${error.stack}`);
return false;
peternied marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
DarshitChanpura marked this conversation as resolved.
Show resolved Hide resolved
Loading