Skip to content

Commit

Permalink
[workspace]feat: Add ACL auditor (opensearch-project#8557)
Browse files Browse the repository at this point in the history
* feat: enable acl auditor

Signed-off-by: SuZhou-Joe <[email protected]>

* Changeset file for PR opensearch-project#8557 created/updated

* feat: optmize code

Signed-off-by: SuZhou-Joe <[email protected]>

* feat: optimize code

Signed-off-by: SuZhou-Joe <[email protected]>

* fix: update workspace metadata is giving error log

Signed-off-by: SuZhou-Joe <[email protected]>

* feat: update

Signed-off-by: SuZhou-Joe <[email protected]>

* feat: refactor clientCallAuditor

Signed-off-by: SuZhou-Joe <[email protected]>

* feat: add unit test

Signed-off-by: SuZhou-Joe <[email protected]>

* feat: update

Signed-off-by: SuZhou-Joe <[email protected]>

* feat: optimize code and wording

Signed-off-by: SuZhou-Joe <[email protected]>

* feat: optimize code and wording

Signed-off-by: SuZhou-Joe <[email protected]>

* feat: add comments

Signed-off-by: SuZhou-Joe <[email protected]>

* feat: update

Signed-off-by: SuZhou-Joe <[email protected]>

* feat: optimize comment

Signed-off-by: SuZhou-Joe <[email protected]>

* fix: type error in workspace_saved_objects_client_wrapper.test.ts

Signed-off-by: SuZhou-Joe <[email protected]>

* feat: update

Signed-off-by: SuZhou-Joe <[email protected]>

* feat: add unit test

Signed-off-by: SuZhou-Joe <[email protected]>

---------

Signed-off-by: SuZhou-Joe <[email protected]>
Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com>
  • Loading branch information
2 people authored and Qxisylolo committed Oct 30, 2024
1 parent 10163cf commit ff6dbfb
Show file tree
Hide file tree
Showing 15 changed files with 869 additions and 123 deletions.
2 changes: 2 additions & 0 deletions changelogs/fragments/8557.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
feat:
- Add ACL auditor ([#8557](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8557))
34 changes: 17 additions & 17 deletions src/core/server/saved_objects/service/saved_objects_client.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,22 +31,22 @@
import { SavedObjectsClientContract } from '../types';
import { SavedObjectsErrorHelpers } from './lib/errors';

const create = () =>
(({
errors: SavedObjectsErrorHelpers,
create: jest.fn(),
bulkCreate: jest.fn(),
checkConflicts: jest.fn(),
bulkUpdate: jest.fn(),
delete: jest.fn(),
bulkGet: jest.fn(),
find: jest.fn(),
get: jest.fn(),
update: jest.fn(),
addToNamespaces: jest.fn(),
deleteFromNamespaces: jest.fn(),
addToWorkspaces: jest.fn(),
deleteFromWorkspaces: jest.fn(),
} as unknown) as jest.Mocked<SavedObjectsClientContract>);
const create = (): jest.Mocked<SavedObjectsClientContract> => ({
errors: SavedObjectsErrorHelpers as jest.Mocked<SavedObjectsClientContract>['errors'],
create: jest.fn(),
bulkCreate: jest.fn(),
checkConflicts: jest.fn(),
bulkUpdate: jest.fn(),
delete: jest.fn(),
bulkGet: jest.fn(),
find: jest.fn(),
get: jest.fn(),
update: jest.fn(),
addToNamespaces: jest.fn(),
deleteFromNamespaces: jest.fn(),
addToWorkspaces: jest.fn(),
deleteFromWorkspaces: jest.fn(),
deleteByWorkspace: jest.fn(),
});

export const savedObjectsClientMock = { create };
51 changes: 51 additions & 0 deletions src/core/server/utils/acl_auditor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { loggerMock } from '../logging/logger.mock';
import { httpServerMock } from '../mocks';
import {
initializeACLAuditor,
getACLAuditor,
cleanUpACLAuditor,
ACLAuditorStateKey,
} from './acl_auditor';

describe('#getACLAuditor', () => {
it('should be able to get ACL auditor if request initialized', () => {
const mockRequest = httpServerMock.createOpenSearchDashboardsRequest();
const uninilizedMockRequest = httpServerMock.createOpenSearchDashboardsRequest();
const mockedLogger = loggerMock.create();
initializeACLAuditor(mockRequest, mockedLogger);
expect(getACLAuditor(mockRequest)).not.toBeFalsy();
expect(getACLAuditor(uninilizedMockRequest)).toBeFalsy();
});
});

describe('#cleanUpACLAuditor', () => {
it('should be able to destroy the auditor', () => {
const mockRequest = httpServerMock.createOpenSearchDashboardsRequest();
const mockedLogger = loggerMock.create();
initializeACLAuditor(mockRequest, mockedLogger);
expect(getACLAuditor(mockRequest)).not.toBeFalsy();
cleanUpACLAuditor(mockRequest);
expect(getACLAuditor(mockRequest)).toBeFalsy();
});
});

describe('#ACLAuditor', () => {
it('should log error when auditor value is not correct', () => {
const mockRequest = httpServerMock.createOpenSearchDashboardsRequest();
const mockedLogger = loggerMock.create();
initializeACLAuditor(mockRequest, mockedLogger);
const ACLAuditorInstance = getACLAuditor(mockRequest);
ACLAuditorInstance?.increment(ACLAuditorStateKey.DATABASE_OPERATION, 1);
ACLAuditorInstance?.checkout();
expect(
mockedLogger.error.mock.calls[0][0].toString().startsWith('[ACLCounterCheckoutFailed]')
).toEqual(true);
ACLAuditorInstance?.checkout();
expect(mockedLogger.error).toBeCalledTimes(1);
});
});
97 changes: 97 additions & 0 deletions src/core/server/utils/acl_auditor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { OpenSearchDashboardsRequest, ensureRawRequest } from '../http/router';
import { Logger } from '../logging';

const ACLAuditorKey = Symbol('ACLAuditor');

export const ACLAuditorStateKey = {
VALIDATE_SUCCESS: 'validateSuccess',
VALIDATE_FAILURE: 'validateFailure',
DATABASE_OPERATION: 'databaseOperation',
} as const;

const defaultState = {
[ACLAuditorStateKey.VALIDATE_SUCCESS]: 0,
[ACLAuditorStateKey.VALIDATE_FAILURE]: 0,
[ACLAuditorStateKey.DATABASE_OPERATION]: 0,
};

type ValueOf<T> = T[keyof T];

class ACLAuditor {
private state = { ...defaultState };

constructor(private logger: Logger) {}

reset = () => {
this.state = { ...defaultState };
};

increment = (key: ValueOf<typeof ACLAuditorStateKey>, count: number) => {
if (typeof count !== 'number' || !this.state.hasOwnProperty(key)) {
return;
}

this.state[key] = this.state[key] + count;
};

checkout = (requestInfo?: string) => {
/**
* VALIDATE_FAILURE represents the count for unauthorized call to a specific objects
* VALIDATE_SUCCESS represents the count for authorized call to a specific objects
* DATABASE_OPERATION represents the count for operations call to the database.
*
* Normally the operations call to the database should always <= the AuthZ check(VALIDATE_FAILURE + VALIDATE_SUCCESS)
* If DATABASE_OPERATION > AuthZ check, it means we have somewhere bypasses the AuthZ check and we should audit this bypass behavior.
*/
if (
this.state[ACLAuditorStateKey.VALIDATE_FAILURE] +
this.state[ACLAuditorStateKey.VALIDATE_SUCCESS] <
this.state[ACLAuditorStateKey.DATABASE_OPERATION]
) {
this.logger.error(
`[ACLCounterCheckoutFailed] counter state: ${JSON.stringify(this.state)}, ${
requestInfo ? `requestInfo: ${requestInfo}` : ''
}`
);
}

this.reset();
};

getState = () => this.state;
}

interface AppState {
[ACLAuditorKey]?: ACLAuditor;
}

/**
* This function will be used to initialize a new app state to the request
*
* @param request OpenSearchDashboardsRequest
* @returns void
*/
export const initializeACLAuditor = (request: OpenSearchDashboardsRequest, logger: Logger) => {
const rawRequest = ensureRawRequest(request);
const appState: AppState = rawRequest.app;
const ACLCounterInstance = appState[ACLAuditorKey];

if (ACLCounterInstance) {
return;
}

appState[ACLAuditorKey] = new ACLAuditor(logger);
};

export const getACLAuditor = (request: OpenSearchDashboardsRequest): ACLAuditor | undefined => {
return (ensureRawRequest(request).app as AppState)[ACLAuditorKey];
};

export const cleanUpACLAuditor = (request: OpenSearchDashboardsRequest) => {
(ensureRawRequest(request).app as AppState)[ACLAuditorKey] = undefined;
};
42 changes: 42 additions & 0 deletions src/core/server/utils/client_call_auditor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { httpServerMock } from '../../../core/server/mocks';
import {
initializeClientCallAuditor,
getClientCallAuditor,
CLIENT_CALL_AUDITOR_KEY,
cleanUpClientCallAuditor,
} from './client_call_auditor';

describe('#getClientCallAuditor', () => {
it('should be able to get clientCallAuditor if request initialized', () => {
const mockRequest = httpServerMock.createOpenSearchDashboardsRequest();
const uninilizedMockRequest = httpServerMock.createOpenSearchDashboardsRequest();
initializeClientCallAuditor(mockRequest);
expect(getClientCallAuditor(mockRequest)).not.toBeFalsy();
expect(getClientCallAuditor(uninilizedMockRequest)).toBeFalsy();
});
});

describe('#cleanUpClientCallAuditor', () => {
it('should be able to destroy the auditor', () => {
const mockRequest = httpServerMock.createOpenSearchDashboardsRequest();
initializeClientCallAuditor(mockRequest);
expect(getClientCallAuditor(mockRequest)).not.toBeFalsy();
cleanUpClientCallAuditor(mockRequest);
expect(getClientCallAuditor(mockRequest)).toBeFalsy();
});
});

describe('#ClientCallAuditor', () => {
it('should return false when auditor incoming not equal outgoing', () => {
const mockRequest = httpServerMock.createOpenSearchDashboardsRequest();
initializeClientCallAuditor(mockRequest);
const ACLAuditorInstance = getClientCallAuditor(mockRequest);
ACLAuditorInstance?.increment(CLIENT_CALL_AUDITOR_KEY.incoming);
expect(ACLAuditorInstance?.isAsyncClientCallsBalanced()).toEqual(false);
});
});
63 changes: 63 additions & 0 deletions src/core/server/utils/client_call_auditor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { OpenSearchDashboardsRequest, ensureRawRequest } from '../http/router';

const clientCallAuditorKey = Symbol('clientCallAuditor');

interface AppState {
[clientCallAuditorKey]?: ClientCallAuditor;
}

export const CLIENT_CALL_AUDITOR_KEY = {
incoming: 'incoming',
outgoing: 'outgoing',
} as const;

/**
* This class will be used to audit all the async calls to saved objects client.
* For example, `/api/sample_data` will call savedObjectsClient.get() 3 times parallely and for ACL auditor,
* it should only `checkout` when the incoming calls equal outgoing call.
*/
class ClientCallAuditor {
private state: {
incoming?: number;
outgoing?: number;
} = {};
increment(key: keyof typeof CLIENT_CALL_AUDITOR_KEY) {
this.state[key] = (this.state[key] || 0) + 1;
}
isAsyncClientCallsBalanced() {
return this.state.incoming === this.state.outgoing;
}
}

/**
* This function will be used to initialize a new app state to the request
*
* @param request OpenSearchDashboardsRequest
* @returns void
*/
export const initializeClientCallAuditor = (request: OpenSearchDashboardsRequest) => {
const rawRequest = ensureRawRequest(request);
const appState: AppState = rawRequest.app;
const clientCallAuditorInstance = appState[clientCallAuditorKey];

if (clientCallAuditorInstance) {
return;
}

appState[clientCallAuditorKey] = new ClientCallAuditor();
};

export const getClientCallAuditor = (
request: OpenSearchDashboardsRequest
): ClientCallAuditor | undefined => {
return (ensureRawRequest(request).app as AppState)[clientCallAuditorKey];
};

export const cleanUpClientCallAuditor = (request: OpenSearchDashboardsRequest) => {
(ensureRawRequest(request).app as AppState)[clientCallAuditorKey] = undefined;
};
12 changes: 12 additions & 0 deletions src/core/server/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,15 @@ export * from './streams';
export { getPrincipalsFromRequest } from './auth_info';
export { getWorkspaceIdFromUrl, cleanWorkspaceId } from '../../utils';
export { updateWorkspaceState, getWorkspaceState } from './workspace';
export {
ACLAuditorStateKey,
initializeACLAuditor,
getACLAuditor,
cleanUpACLAuditor,
} from './acl_auditor';
export {
CLIENT_CALL_AUDITOR_KEY,
getClientCallAuditor,
initializeClientCallAuditor,
cleanUpClientCallAuditor,
} from './client_call_auditor';
6 changes: 6 additions & 0 deletions src/plugins/workspace/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ export const PRIORITY_FOR_WORKSPACE_UI_SETTINGS_WRAPPER = -2;
export const PRIORITY_FOR_WORKSPACE_CONFLICT_CONTROL_WRAPPER = -1;
export const PRIORITY_FOR_PERMISSION_CONTROL_WRAPPER = 0;

/**
* The repository wrapper should be the wrapper closest to the repository client,
* so we give a large number to the wrapper
*/
export const PRIORITY_FOR_REPOSITORY_WRAPPER = Number.MAX_VALUE;

/**
*
* This is a temp solution to store relationships between use cases and features.
Expand Down
9 changes: 8 additions & 1 deletion src/plugins/workspace/server/permission_control/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ import {
HttpAuth,
} from '../../../../core/server';
import { WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID } from '../../common/constants';
import { getPrincipalsFromRequest } from '../../../../core/server/utils';
import {
ACLAuditorStateKey,
getACLAuditor,
getPrincipalsFromRequest,
} from '../../../../core/server/utils';

export type SavedObjectsPermissionControlContract = Pick<
SavedObjectsPermissionControl,
Expand Down Expand Up @@ -57,6 +61,7 @@ export class SavedObjectsPermissionControl {
request: OpenSearchDashboardsRequest,
savedObjects: SavedObjectsBulkGetObject[]
) {
const ACLAuditor = getACLAuditor(request);
const requestKey = request.uuid;
const savedObjectsToGet = savedObjects.filter(
(savedObject) =>
Expand All @@ -66,6 +71,8 @@ export class SavedObjectsPermissionControl {
savedObjectsToGet.length > 0
? (await this.getScopedClient?.(request)?.bulkGet(savedObjectsToGet))?.saved_objects || []
: [];
// System request, -1 * savedObjectsToGet.length for compensation.
ACLAuditor?.increment(ACLAuditorStateKey.DATABASE_OPERATION, -1 * savedObjectsToGet.length);

const retrievedSavedObjectsMap: { [key: string]: SavedObject } = {};
retrievedSavedObjects.forEach((savedObject) => {
Expand Down
Loading

0 comments on commit ff6dbfb

Please sign in to comment.