mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Saved Objects] Consolidates Check & Enforce Authz Extension Methods (#147287)
Resolves #147045 Combines the Saved Objects Security Extension's Check Authorization and Enforce Authorization methods into a single Perform Authorization method to simplify usage and prepare for migration of audit & authorization logic from the Saved Objects Repository to the Security Extension. ## Follow-on Work: - https://github.com/elastic/kibana/issues/147048 - https://github.com/elastic/kibana/issues/147049 ## Testing ### Unit Tests [ ] repository.security_extension.test.ts [ ] repository.spaces_extension.test.ts [ ] collect_multi_namespace_references.test.ts [ ] internal_bulk_resolve.test.ts [ ] update_objects_spaces.test.ts [ ] saved_objects_security_extension.test.ts [ ] secure_spaces_client_wrapper.test.ts Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
800f45180c
commit
88733fc48f
22 changed files with 1335 additions and 1248 deletions
|
@ -17,7 +17,11 @@ import type {
|
|||
SavedObjectsCollectMultiNamespaceReferencesObject,
|
||||
SavedObjectsCollectMultiNamespaceReferencesOptions,
|
||||
} from '@kbn/core-saved-objects-api-server';
|
||||
import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-utils-server';
|
||||
import {
|
||||
setMapsAreEqual,
|
||||
SavedObjectsErrorHelpers,
|
||||
setsAreEqual,
|
||||
} from '@kbn/core-saved-objects-utils-server';
|
||||
import { SavedObjectsSerializer } from '@kbn/core-saved-objects-base-server-internal';
|
||||
import { typeRegistryMock } from '@kbn/core-saved-objects-base-server-mocks';
|
||||
import {
|
||||
|
@ -31,12 +35,8 @@ import { AuditAction, type ISavedObjectsSecurityExtension } from '@kbn/core-save
|
|||
import {
|
||||
authMap,
|
||||
enforceError,
|
||||
typeMapsAreEqual,
|
||||
setsAreEqual,
|
||||
setupCheckAuthorized,
|
||||
setupCheckUnauthorized,
|
||||
setupEnforceFailure,
|
||||
setupEnforceSuccess,
|
||||
setupPerformAuthFullyAuthorized,
|
||||
setupPerformAuthEnforceFailure,
|
||||
setupRedactPassthrough,
|
||||
} from '../test_helpers/repository.test.common';
|
||||
import { savedObjectsExtensionsMock } from '../mocks/saved_objects_extensions.mock';
|
||||
|
@ -487,7 +487,7 @@ describe('collectMultiNamespaceReferences', () => {
|
|||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockSecurityExt.checkAuthorization.mockReset();
|
||||
mockSecurityExt.performAuthorization.mockReset();
|
||||
mockSecurityExt.enforceAuthorization.mockReset();
|
||||
mockSecurityExt.redactNamespaces.mockReset();
|
||||
mockSecurityExt.addAuditEvent.mockReset();
|
||||
|
@ -495,25 +495,21 @@ describe('collectMultiNamespaceReferences', () => {
|
|||
|
||||
describe(`errors`, () => {
|
||||
test(`propagates decorated error when not authorized`, async () => {
|
||||
setupCheckUnauthorized(mockSecurityExt);
|
||||
// Unlike other functions, it doesn't validate the level of authorization first, so we need to
|
||||
// carry on and mock the enforce function as well to create an unauthorized condition
|
||||
setupEnforceFailure(mockSecurityExt);
|
||||
setupPerformAuthEnforceFailure(mockSecurityExt);
|
||||
|
||||
await expect(collectMultiNamespaceReferences(params)).rejects.toThrow(enforceError);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.performAuthorization).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test(`adds audit event per object when not successful`, async () => {
|
||||
setupCheckUnauthorized(mockSecurityExt);
|
||||
// Unlike other functions, it doesn't validate the level of authorization first, so we need to
|
||||
// carry on and mock the enforce function as well to create an unauthorized condition
|
||||
setupEnforceFailure(mockSecurityExt);
|
||||
setupPerformAuthEnforceFailure(mockSecurityExt);
|
||||
|
||||
await expect(collectMultiNamespaceReferences(params)).rejects.toThrow(enforceError);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.performAuthorization).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(objects.length);
|
||||
objects.forEach((obj) => {
|
||||
|
@ -528,23 +524,19 @@ describe('collectMultiNamespaceReferences', () => {
|
|||
|
||||
describe('checks privileges', () => {
|
||||
beforeEach(() => {
|
||||
setupCheckUnauthorized(mockSecurityExt);
|
||||
setupEnforceFailure(mockSecurityExt);
|
||||
setupPerformAuthEnforceFailure(mockSecurityExt);
|
||||
});
|
||||
test(`in the default state`, async () => {
|
||||
await expect(collectMultiNamespaceReferences(params)).rejects.toThrow(enforceError);
|
||||
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.performAuthorization).toHaveBeenCalledTimes(1);
|
||||
const expectedSpaces = new Set(['default', ...SPACES, ...obj1LegacySpaces]);
|
||||
const { spaces: actualSpaces } = mockSecurityExt.checkAuthorization.mock.calls[0][0];
|
||||
const expectedEnforceMap = new Map([[objects[0].type, new Set(['default'])]]);
|
||||
|
||||
const { spaces: actualSpaces, enforceMap: actualEnforceMap } =
|
||||
mockSecurityExt.performAuthorization.mock.calls[0][0];
|
||||
expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy();
|
||||
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
const expectedTypesAndSpaces = new Map([[objects[0].type, new Set(['default'])]]);
|
||||
const { typesAndSpaces: actualTypesAndSpaces } =
|
||||
mockSecurityExt.enforceAuthorization.mock.calls[0][0];
|
||||
|
||||
expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy();
|
||||
expect(setMapsAreEqual(actualEnforceMap, expectedEnforceMap)).toBeTruthy();
|
||||
});
|
||||
|
||||
test(`in a non-default state`, async () => {
|
||||
|
@ -553,17 +545,13 @@ describe('collectMultiNamespaceReferences', () => {
|
|||
collectMultiNamespaceReferences({ ...params, options: { namespace } })
|
||||
).rejects.toThrow(enforceError);
|
||||
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.performAuthorization).toHaveBeenCalledTimes(1);
|
||||
const expectedSpaces = new Set([namespace, ...SPACES, ...obj1LegacySpaces]);
|
||||
const { spaces: actualSpaces } = mockSecurityExt.checkAuthorization.mock.calls[0][0];
|
||||
const expectedEnforceMap = new Map([[objects[0].type, new Set([namespace])]]);
|
||||
const { spaces: actualSpaces, enforceMap: actualEnforceMap } =
|
||||
mockSecurityExt.performAuthorization.mock.calls[0][0];
|
||||
expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy();
|
||||
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
const expectedTypesAndSpaces = new Map([[objects[0].type, new Set([namespace])]]);
|
||||
const { typesAndSpaces: actualTypesAndSpaces } =
|
||||
mockSecurityExt.enforceAuthorization.mock.calls[0][0];
|
||||
|
||||
expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy();
|
||||
expect(setMapsAreEqual(actualEnforceMap, expectedEnforceMap)).toBeTruthy();
|
||||
});
|
||||
|
||||
test(`with purpose 'collectMultiNamespaceReferences'`, async () => {
|
||||
|
@ -571,19 +559,17 @@ describe('collectMultiNamespaceReferences', () => {
|
|||
purpose: 'collectMultiNamespaceReferences',
|
||||
};
|
||||
|
||||
setupCheckUnauthorized(mockSecurityExt);
|
||||
setupEnforceFailure(mockSecurityExt);
|
||||
setupPerformAuthEnforceFailure(mockSecurityExt);
|
||||
|
||||
await expect(collectMultiNamespaceReferences({ ...params, options })).rejects.toThrow(
|
||||
enforceError
|
||||
);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.checkAuthorization).toBeCalledWith(
|
||||
expect(mockSecurityExt.performAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.performAuthorization).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
actions: new Set(['bulk_get']),
|
||||
})
|
||||
);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test(`with purpose 'updateObjectsSpaces'`, async () => {
|
||||
|
@ -591,45 +577,40 @@ describe('collectMultiNamespaceReferences', () => {
|
|||
purpose: 'updateObjectsSpaces',
|
||||
};
|
||||
|
||||
setupCheckUnauthorized(mockSecurityExt);
|
||||
setupEnforceFailure(mockSecurityExt);
|
||||
setupPerformAuthEnforceFailure(mockSecurityExt);
|
||||
|
||||
await expect(collectMultiNamespaceReferences({ ...params, options })).rejects.toThrow(
|
||||
enforceError
|
||||
);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.checkAuthorization).toBeCalledWith(
|
||||
expect(mockSecurityExt.performAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.performAuthorization).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
actions: new Set(['share_to_space']),
|
||||
})
|
||||
);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('success', () => {
|
||||
beforeEach(async () => {
|
||||
setupCheckAuthorized(mockSecurityExt);
|
||||
setupEnforceSuccess(mockSecurityExt);
|
||||
setupPerformAuthFullyAuthorized(mockSecurityExt);
|
||||
setupRedactPassthrough(mockSecurityExt);
|
||||
await collectMultiNamespaceReferences(params);
|
||||
});
|
||||
test(`calls redactNamespaces with type, spaces, and authorization map`, async () => {
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.performAuthorization).toHaveBeenCalledTimes(1);
|
||||
const expectedSpaces = new Set(['default', ...SPACES, ...obj1LegacySpaces]);
|
||||
const { spaces: actualSpaces } = mockSecurityExt.checkAuthorization.mock.calls[0][0];
|
||||
const { spaces: actualSpaces } = mockSecurityExt.performAuthorization.mock.calls[0][0];
|
||||
expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy();
|
||||
|
||||
const resultObjects = [obj1, obj2, obj3];
|
||||
|
||||
// enforce is called once for all objects/spaces, then once per object
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(
|
||||
1 + resultObjects.length
|
||||
);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(resultObjects.length);
|
||||
const expectedTypesAndSpaces = new Map([[objects[0].type, new Set(['default'])]]);
|
||||
const { typesAndSpaces: actualTypesAndSpaces } =
|
||||
mockSecurityExt.enforceAuthorization.mock.calls[0][0];
|
||||
expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy();
|
||||
expect(setMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy();
|
||||
|
||||
// Redact is called once per object, but an additional time for object 1 because it has legacy URL aliases in another set of spaces
|
||||
expect(mockSecurityExt.redactNamespaces).toBeCalledTimes(resultObjects.length + 1);
|
||||
|
@ -653,15 +634,10 @@ describe('collectMultiNamespaceReferences', () => {
|
|||
});
|
||||
|
||||
test(`adds audit event per object when successful`, async () => {
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.performAuthorization).toHaveBeenCalledTimes(1);
|
||||
|
||||
const resultObjects = [obj1, obj2, obj3];
|
||||
|
||||
// enforce is called once for all objects/spaces, then once per object
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(
|
||||
1 + resultObjects.length
|
||||
);
|
||||
|
||||
expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(resultObjects.length);
|
||||
resultObjects.forEach((obj) => {
|
||||
expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({
|
||||
|
|
|
@ -252,21 +252,18 @@ async function optionallyUseSecurity(
|
|||
}
|
||||
const action =
|
||||
purpose === 'updateObjectsSpaces' ? ('share_to_space' as const) : ('bulk_get' as const);
|
||||
const { typeMap } = await securityExtension.checkAuthorization({
|
||||
types: typesToAuthorize,
|
||||
spaces: spacesToAuthorize,
|
||||
actions: new Set([action]),
|
||||
});
|
||||
|
||||
// Enforce authorization based on all *requested* object types and the current space
|
||||
const typesAndSpaces = objects.reduce(
|
||||
(acc, { type }) => (acc.has(type) ? acc : acc.set(type, new Set([namespaceString]))), // Always enforce authZ for the active space
|
||||
new Map<string, Set<string>>()
|
||||
);
|
||||
securityExtension!.enforceAuthorization({
|
||||
typesAndSpaces,
|
||||
action,
|
||||
typeMap,
|
||||
|
||||
const { typeMap } = await securityExtension?.performAuthorization({
|
||||
actions: new Set([action]),
|
||||
types: typesToAuthorize,
|
||||
spaces: spacesToAuthorize,
|
||||
enforceMap: typesAndSpaces,
|
||||
auditCallback: (error) => {
|
||||
if (!error) return; // We will audit success results below, after redaction
|
||||
for (const { type, id } of objects) {
|
||||
|
@ -307,6 +304,9 @@ async function optionallyUseSecurity(
|
|||
// Is the user authorized to access this object in this space?
|
||||
let isAuthorizedForObject = true;
|
||||
try {
|
||||
// ToDo: this is the only remaining call to enforceAuthorization outside of the security extension
|
||||
// This was a bit complicated to change now, but can ultimately be removed when authz logic is
|
||||
// migrated from the repo level to the extension level.
|
||||
securityExtension.enforceAuthorization({
|
||||
typesAndSpaces: new Map([[type, new Set([namespaceString])]]),
|
||||
action,
|
||||
|
|
|
@ -18,7 +18,12 @@ import type {
|
|||
SavedObjectsBulkResolveObject,
|
||||
SavedObjectsBaseOptions,
|
||||
} from '@kbn/core-saved-objects-api-server';
|
||||
import { SavedObjectsErrorHelpers, SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server';
|
||||
import {
|
||||
setMapsAreEqual,
|
||||
SavedObjectsErrorHelpers,
|
||||
SavedObjectsUtils,
|
||||
setsAreEqual,
|
||||
} from '@kbn/core-saved-objects-utils-server';
|
||||
import {
|
||||
SavedObjectsSerializer,
|
||||
LEGACY_URL_ALIAS_TYPE,
|
||||
|
@ -35,12 +40,8 @@ import {
|
|||
import {
|
||||
authMap,
|
||||
enforceError,
|
||||
typeMapsAreEqual,
|
||||
setsAreEqual,
|
||||
setupCheckAuthorized,
|
||||
setupCheckUnauthorized,
|
||||
setupEnforceFailure,
|
||||
setupEnforceSuccess,
|
||||
setupPerformAuthFullyAuthorized,
|
||||
setupPerformAuthEnforceFailure,
|
||||
setupRedactPassthrough,
|
||||
} from '../test_helpers/repository.test.common';
|
||||
import { savedObjectsExtensionsMock } from '../mocks/saved_objects_extensions.mock';
|
||||
|
@ -464,22 +465,18 @@ describe('internalBulkResolve', () => {
|
|||
});
|
||||
|
||||
test(`propagates decorated error when unauthorized`, async () => {
|
||||
setupCheckUnauthorized(mockSecurityExt);
|
||||
setupEnforceFailure(mockSecurityExt);
|
||||
setupPerformAuthEnforceFailure(mockSecurityExt);
|
||||
|
||||
await expect(internalBulkResolve(params)).rejects.toThrow(enforceError);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.performAuthorization).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test(`returns result when authorized`, async () => {
|
||||
setupCheckAuthorized(mockSecurityExt);
|
||||
setupEnforceSuccess(mockSecurityExt);
|
||||
setupPerformAuthFullyAuthorized(mockSecurityExt);
|
||||
setupRedactPassthrough(mockSecurityExt);
|
||||
|
||||
const result = await internalBulkResolve(params);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.performAuthorization).toHaveBeenCalledTimes(1);
|
||||
|
||||
const bulkIds = objects.map((obj) => obj.id);
|
||||
const expectedNamespaceString = SavedObjectsUtils.namespaceIdToString(namespace);
|
||||
|
@ -498,57 +495,38 @@ describe('internalBulkResolve', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
test(`calls checkAuthorization with type, actions, namespace, and object namespaces`, async () => {
|
||||
setupCheckAuthorized(mockSecurityExt);
|
||||
setupEnforceSuccess(mockSecurityExt);
|
||||
test(`calls performAuthorization with correct actions, types, spaces, and enforce map`, async () => {
|
||||
setupPerformAuthFullyAuthorized(mockSecurityExt);
|
||||
|
||||
await internalBulkResolve(params);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.performAuthorization).toHaveBeenCalledTimes(1);
|
||||
const expectedActions = new Set(['bulk_get']);
|
||||
const expectedSpaces = new Set([namespace]);
|
||||
const expectedTypes = new Set([objects[0].type]);
|
||||
const expectedEnforceMap = new Map<string, Set<string>>();
|
||||
expectedEnforceMap.set(objects[0].type, new Set([namespace]));
|
||||
|
||||
const {
|
||||
actions: actualActions,
|
||||
spaces: actualSpaces,
|
||||
types: actualTypes,
|
||||
} = mockSecurityExt.checkAuthorization.mock.calls[0][0];
|
||||
enforceMap: actualEnforceMap,
|
||||
options: actualOptions,
|
||||
} = mockSecurityExt.performAuthorization.mock.calls[0][0];
|
||||
|
||||
expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy();
|
||||
expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy();
|
||||
expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy();
|
||||
});
|
||||
|
||||
test(`calls enforceAuthorization with action, type map, and auth map`, async () => {
|
||||
setupCheckAuthorized(mockSecurityExt);
|
||||
setupEnforceSuccess(mockSecurityExt);
|
||||
|
||||
await internalBulkResolve(params);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'bulk_get',
|
||||
})
|
||||
);
|
||||
|
||||
const expectedTypesAndSpaces = new Map([[objects[0].type, new Set([namespace])]]);
|
||||
|
||||
const { typesAndSpaces: actualTypesAndSpaces, typeMap: actualTypeMap } =
|
||||
mockSecurityExt.enforceAuthorization.mock.calls[0][0];
|
||||
|
||||
expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy();
|
||||
expect(actualTypeMap).toBe(authMap);
|
||||
expect(setMapsAreEqual(actualEnforceMap, expectedEnforceMap)).toBeTruthy();
|
||||
expect(actualOptions).toBeUndefined();
|
||||
});
|
||||
|
||||
test(`calls redactNamespaces with authorization map`, async () => {
|
||||
setupCheckAuthorized(mockSecurityExt);
|
||||
setupEnforceSuccess(mockSecurityExt);
|
||||
setupPerformAuthFullyAuthorized(mockSecurityExt);
|
||||
setupRedactPassthrough(mockSecurityExt);
|
||||
|
||||
await internalBulkResolve(params);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.performAuthorization).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockSecurityExt.redactNamespaces).toHaveBeenCalledTimes(objects.length);
|
||||
objects.forEach((obj, i) => {
|
||||
|
@ -565,8 +543,7 @@ describe('internalBulkResolve', () => {
|
|||
});
|
||||
|
||||
test(`adds audit event per object when successful`, async () => {
|
||||
setupCheckAuthorized(mockSecurityExt);
|
||||
setupEnforceSuccess(mockSecurityExt);
|
||||
setupPerformAuthFullyAuthorized(mockSecurityExt);
|
||||
|
||||
await internalBulkResolve(params);
|
||||
|
||||
|
@ -581,8 +558,7 @@ describe('internalBulkResolve', () => {
|
|||
});
|
||||
|
||||
test(`adds audit event per object when not successful`, async () => {
|
||||
setupCheckAuthorized(mockSecurityExt);
|
||||
setupEnforceFailure(mockSecurityExt);
|
||||
setupPerformAuthEnforceFailure(mockSecurityExt);
|
||||
|
||||
await expect(internalBulkResolve(params)).rejects.toThrow(enforceError);
|
||||
|
||||
|
|
|
@ -324,15 +324,11 @@ async function authorizeAuditAndRedact<T>(
|
|||
return resolvedObjects;
|
||||
}
|
||||
|
||||
const authorizationResult = await securityExtension.checkAuthorization({
|
||||
const authorizationResult = await securityExtension?.performAuthorization({
|
||||
actions: new Set(['bulk_get']),
|
||||
types: new Set(typesAndSpaces.keys()),
|
||||
spaces: spacesToAuthorize,
|
||||
actions: new Set(['bulk_get']),
|
||||
});
|
||||
securityExtension.enforceAuthorization({
|
||||
typesAndSpaces,
|
||||
action: 'bulk_get',
|
||||
typeMap: authorizationResult.typeMap,
|
||||
enforceMap: typesAndSpaces,
|
||||
auditCallback: (error) => {
|
||||
for (const { type, id } of auditableObjects) {
|
||||
securityExtension.addAuditEvent({
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -53,7 +53,7 @@ import {
|
|||
bulkCreateSuccess,
|
||||
bulkUpdateSuccess,
|
||||
findSuccess,
|
||||
setupCheckUnauthorized,
|
||||
setupPerformAuthUnauthorized,
|
||||
generateIndexPatternSearchResults,
|
||||
bulkDeleteSuccess,
|
||||
} from '../test_helpers/repository.test.common';
|
||||
|
@ -912,7 +912,7 @@ describe('SavedObjectsRepository Spaces Extension', () => {
|
|||
|
||||
describe(`#find`, () => {
|
||||
test(`returns empty result if user is unauthorized`, async () => {
|
||||
setupCheckUnauthorized(mockSecurityExt);
|
||||
setupPerformAuthUnauthorized(mockSecurityExt);
|
||||
const type = 'index-pattern';
|
||||
const spaceOverride = 'ns-4';
|
||||
const generatedResults = generateIndexPatternSearchResults(spaceOverride);
|
||||
|
|
|
@ -355,31 +355,25 @@ export class SavedObjectsRepository implements ISavedObjectsRepository {
|
|||
const existingNamespaces = preflightResult?.existingDocument?._source?.namespaces || [];
|
||||
const spacesToAuthorize = new Set(existingNamespaces);
|
||||
spacesToAuthorize.delete(ALL_NAMESPACES_STRING); // Don't accidentally check for global privileges when the object exists in '*'
|
||||
const authorizationResult = await this._securityExtension?.checkAuthorization({
|
||||
types: new Set([type]),
|
||||
spaces: new Set([...spacesToEnforce, ...spacesToAuthorize]), // existing namespaces are included so we can later redact if necessary
|
||||
actions: new Set(['create']),
|
||||
const authorizationResult = await this._securityExtension?.performAuthorization({
|
||||
// If a user tries to create an object with `initialNamespaces: ['*']`, they need to have 'create' privileges for the Global Resource
|
||||
// (e.g., All privileges for All Spaces).
|
||||
// Inversely, if a user tries to overwrite an object that already exists in '*', they don't need to 'create' privileges for the Global
|
||||
// Resource, so in that case we have to filter out that string from spacesToAuthorize (because `allowGlobalResource: true` is used
|
||||
// below.)
|
||||
actions: new Set(['create']),
|
||||
types: new Set([type]),
|
||||
spaces: new Set([...spacesToEnforce, ...spacesToAuthorize]), // existing namespaces are included so we can later redact if necessary
|
||||
enforceMap: new Map([[type, spacesToEnforce]]),
|
||||
auditCallback: (error) =>
|
||||
this._securityExtension!.addAuditEvent({
|
||||
action: AuditAction.CREATE,
|
||||
savedObject: { type, id },
|
||||
error,
|
||||
...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the create operation has not occurred yet
|
||||
}),
|
||||
options: { allowGlobalResource: true },
|
||||
});
|
||||
if (authorizationResult) {
|
||||
this._securityExtension!.enforceAuthorization({
|
||||
typesAndSpaces: new Map([[type, spacesToEnforce]]),
|
||||
action: 'create',
|
||||
typeMap: authorizationResult.typeMap,
|
||||
auditCallback: (error) =>
|
||||
this._securityExtension!.addAuditEvent({
|
||||
action: AuditAction.CREATE,
|
||||
savedObject: { type, id },
|
||||
error,
|
||||
...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the create operation has not occurred yet
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (preflightResult?.error) {
|
||||
// This intentionally occurs _after_ the authZ enforcement (which may throw a 403 error earlier)
|
||||
|
@ -548,34 +542,28 @@ export class SavedObjectsRepository implements ISavedObjectsRepository {
|
|||
}
|
||||
}
|
||||
|
||||
const authorizationResult = await this._securityExtension?.checkAuthorization({
|
||||
types: new Set(typesAndSpaces.keys()),
|
||||
spaces: spacesToAuthorize,
|
||||
actions: new Set(['bulk_create']),
|
||||
const authorizationResult = await this._securityExtension?.performAuthorization({
|
||||
// If a user tries to create an object with `initialNamespaces: ['*']`, they need to have 'bulk_create' privileges for the Global
|
||||
// Resource (e.g., All privileges for All Spaces).
|
||||
// Inversely, if a user tries to overwrite an object that already exists in '*', they don't need to have 'bulk_create' privileges for the Global
|
||||
// Resource, so in that case we have to filter out that string from spacesToAuthorize (because `allowGlobalResource: true` is used
|
||||
// below.)
|
||||
actions: new Set(['bulk_create']),
|
||||
types: new Set(typesAndSpaces.keys()),
|
||||
spaces: spacesToAuthorize,
|
||||
enforceMap: typesAndSpaces,
|
||||
auditCallback: (error) => {
|
||||
for (const { value } of validObjects) {
|
||||
this._securityExtension!.addAuditEvent({
|
||||
action: AuditAction.CREATE,
|
||||
savedObject: { type: value.object.type, id: value.object.id },
|
||||
error,
|
||||
...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the create operation has not occurred yet
|
||||
});
|
||||
}
|
||||
},
|
||||
options: { allowGlobalResource: true },
|
||||
});
|
||||
if (authorizationResult) {
|
||||
this._securityExtension!.enforceAuthorization({
|
||||
typesAndSpaces,
|
||||
action: 'bulk_create',
|
||||
typeMap: authorizationResult.typeMap,
|
||||
auditCallback: (error) => {
|
||||
for (const { value } of validObjects) {
|
||||
this._securityExtension!.addAuditEvent({
|
||||
action: AuditAction.CREATE,
|
||||
savedObject: { type: value.object.type, id: value.object.id },
|
||||
error,
|
||||
...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the create operation has not occurred yet
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let bulkRequestIndexCounter = 0;
|
||||
const bulkCreateParams: object[] = [];
|
||||
|
@ -777,21 +765,16 @@ export class SavedObjectsRepository implements ISavedObjectsRepository {
|
|||
for (const { value } of validObjects) {
|
||||
typesAndSpaces.set(value.type, new Set([namespaceString])); // Always enforce authZ for the active space
|
||||
}
|
||||
const authorizationResult = await this._securityExtension?.checkAuthorization({
|
||||
|
||||
await this._securityExtension?.performAuthorization({
|
||||
actions: new Set(['bulk_create']),
|
||||
types: new Set(typesAndSpaces.keys()),
|
||||
spaces: new Set([namespaceString]), // Always check authZ for the active space
|
||||
actions: new Set(['bulk_create']),
|
||||
enforceMap: typesAndSpaces,
|
||||
// auditCallback is intentionally omitted, this function in the previous Security SOC wrapper implementation
|
||||
// did not have audit logging. This is primarily because it is only used by Kibana and is not exposed in a
|
||||
// public HTTP API
|
||||
});
|
||||
if (authorizationResult) {
|
||||
this._securityExtension!.enforceAuthorization({
|
||||
typesAndSpaces,
|
||||
action: 'bulk_create',
|
||||
typeMap: authorizationResult.typeMap,
|
||||
// auditCallback is intentionally omitted, this function in the previous Security SOC wrapper implementation
|
||||
// did not have audit logging. This is primarily because it is only used by Kibana and is not exposed in a
|
||||
// public HTTP API
|
||||
});
|
||||
}
|
||||
|
||||
const bulkGetDocs = validObjects.map(({ value: { type, id } }) => ({
|
||||
_id: this._serializer.generateRawId(namespace, type, id),
|
||||
|
@ -860,26 +843,21 @@ export class SavedObjectsRepository implements ISavedObjectsRepository {
|
|||
|
||||
const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace);
|
||||
const typesAndSpaces = new Map<string, Set<string>>([[type, new Set([namespaceString])]]); // Always enforce authZ for the active space
|
||||
const authorizationResult = await this._securityExtension?.checkAuthorization({
|
||||
|
||||
await this._securityExtension?.performAuthorization({
|
||||
actions: new Set(['delete']),
|
||||
types: new Set([type]),
|
||||
spaces: new Set([namespaceString]), // Always check authZ for the active space
|
||||
actions: new Set(['delete']),
|
||||
enforceMap: typesAndSpaces,
|
||||
auditCallback: (error) => {
|
||||
this._securityExtension!.addAuditEvent({
|
||||
action: AuditAction.DELETE,
|
||||
savedObject: { type, id },
|
||||
error,
|
||||
...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the delete operation has not occurred yet
|
||||
});
|
||||
},
|
||||
});
|
||||
if (authorizationResult) {
|
||||
this._securityExtension!.enforceAuthorization({
|
||||
typesAndSpaces,
|
||||
action: 'delete',
|
||||
typeMap: authorizationResult.typeMap,
|
||||
auditCallback: (error) => {
|
||||
this._securityExtension!.addAuditEvent({
|
||||
action: AuditAction.DELETE,
|
||||
savedObject: { type, id },
|
||||
error,
|
||||
...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the delete operation has not occurred yet
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const rawId = this._serializer.generateRawId(namespace, type, id);
|
||||
let preflightResult: PreflightCheckNamespacesResult | undefined;
|
||||
|
@ -1165,28 +1143,22 @@ export class SavedObjectsRepository implements ISavedObjectsRepository {
|
|||
}
|
||||
}
|
||||
|
||||
const authorizationResult = await this._securityExtension?.checkAuthorization({
|
||||
await this._securityExtension?.performAuthorization({
|
||||
actions: new Set(['bulk_delete']),
|
||||
types: new Set(typesAndSpaces.keys()),
|
||||
spaces: spacesToAuthorize,
|
||||
actions: new Set(['bulk_delete']),
|
||||
enforceMap: typesAndSpaces,
|
||||
auditCallback: (error) => {
|
||||
for (const { value } of expectedBulkDeleteMultiNamespaceDocsResults) {
|
||||
this._securityExtension!.addAuditEvent({
|
||||
action: AuditAction.DELETE,
|
||||
savedObject: { type: value.type, id: value.id },
|
||||
error,
|
||||
...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the delete operation has not occurred yet
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
if (authorizationResult) {
|
||||
this._securityExtension!.enforceAuthorization({
|
||||
typesAndSpaces,
|
||||
action: 'bulk_delete',
|
||||
typeMap: authorizationResult.typeMap,
|
||||
auditCallback: (error) => {
|
||||
for (const { value } of expectedBulkDeleteMultiNamespaceDocsResults) {
|
||||
this._securityExtension!.addAuditEvent({
|
||||
action: AuditAction.DELETE,
|
||||
savedObject: { type: value.type, id: value.id },
|
||||
error,
|
||||
...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the delete operation has not occurred yet
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Filter valid objects
|
||||
const validObjects = expectedBulkDeleteMultiNamespaceDocsResults.filter(isRight);
|
||||
|
@ -1471,10 +1443,10 @@ export class SavedObjectsRepository implements ISavedObjectsRepository {
|
|||
let typeToNamespacesMap: Map<string, string[]> | undefined;
|
||||
let preAuthorizationResult: CheckAuthorizationResult<'find'> | undefined;
|
||||
if (!disableExtensions && this._securityExtension) {
|
||||
preAuthorizationResult = await this._securityExtension.checkAuthorization({
|
||||
preAuthorizationResult = await this._securityExtension.performAuthorization({
|
||||
actions: new Set(['find']),
|
||||
types: new Set(types),
|
||||
spaces: spacesToPreauthorize,
|
||||
actions: new Set(['find']),
|
||||
});
|
||||
if (preAuthorizationResult.status === 'unauthorized') {
|
||||
// If the user is unauthorized to find *anything* they requested, return an empty response
|
||||
|
@ -1584,10 +1556,10 @@ export class SavedObjectsRepository implements ISavedObjectsRepository {
|
|||
spacesToAuthorize.size > spacesToPreauthorize.size
|
||||
? // If there are any namespaces in the object results that were not already checked during pre-authorization, we need *another*
|
||||
// authorization check so we can correctly redact the object namespaces below.
|
||||
await this._securityExtension?.checkAuthorization({
|
||||
await this._securityExtension?.performAuthorization({
|
||||
actions: new Set(['find']),
|
||||
types: new Set(types),
|
||||
spaces: spacesToAuthorize,
|
||||
actions: new Set(['find']),
|
||||
})
|
||||
: undefined;
|
||||
|
||||
|
@ -1753,28 +1725,22 @@ export class SavedObjectsRepository implements ISavedObjectsRepository {
|
|||
}),
|
||||
};
|
||||
|
||||
const authorizationResult = await this._securityExtension?.checkAuthorization({
|
||||
const authorizationResult = await this._securityExtension?.performAuthorization({
|
||||
actions: new Set(['bulk_get']),
|
||||
types: new Set(typesAndSpaces.keys()),
|
||||
spaces: spacesToAuthorize,
|
||||
actions: new Set(['bulk_get']),
|
||||
enforceMap: typesAndSpaces,
|
||||
auditCallback: (error) => {
|
||||
for (const { type, id, error: bulkError } of result.saved_objects) {
|
||||
if (!error && !!bulkError) continue; // Only log success events for objects that were actually found (and are being returned to the user)
|
||||
this._securityExtension!.addAuditEvent({
|
||||
action: AuditAction.GET,
|
||||
savedObject: { type, id },
|
||||
error,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
if (authorizationResult) {
|
||||
this._securityExtension!.enforceAuthorization({
|
||||
typesAndSpaces,
|
||||
action: 'bulk_get',
|
||||
typeMap: authorizationResult.typeMap,
|
||||
auditCallback: (error) => {
|
||||
for (const { type, id, error: bulkError } of result.saved_objects) {
|
||||
if (!error && !!bulkError) continue; // Only log success events for objects that were actually found (and are being returned to the user)
|
||||
this._securityExtension!.addAuditEvent({
|
||||
action: AuditAction.GET,
|
||||
savedObject: { type, id },
|
||||
error,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return this.optionallyDecryptAndRedactBulkResult(result, authorizationResult?.typeMap);
|
||||
}
|
||||
|
@ -1842,28 +1808,23 @@ export class SavedObjectsRepository implements ISavedObjectsRepository {
|
|||
|
||||
const spacesToEnforce = new Set([SavedObjectsUtils.namespaceIdToString(namespace)]); // Always check/enforce authZ for the active space
|
||||
const existingNamespaces = body?._source?.namespaces || [];
|
||||
const authorizationResult = await this._securityExtension?.checkAuthorization({
|
||||
|
||||
const authorizationResult = await this._securityExtension?.performAuthorization({
|
||||
actions: new Set(['get']),
|
||||
types: new Set([type]),
|
||||
spaces: new Set([...spacesToEnforce, ...existingNamespaces]), // existing namespaces are included so we can later redact if necessary
|
||||
actions: new Set(['get']),
|
||||
enforceMap: new Map([[type, spacesToEnforce]]),
|
||||
auditCallback: (error) => {
|
||||
if (error) {
|
||||
this._securityExtension!.addAuditEvent({
|
||||
action: AuditAction.GET,
|
||||
savedObject: { type, id },
|
||||
error,
|
||||
});
|
||||
}
|
||||
// Audit event for success case is added separately below
|
||||
},
|
||||
});
|
||||
if (authorizationResult) {
|
||||
this._securityExtension!.enforceAuthorization({
|
||||
typesAndSpaces: new Map([[type, spacesToEnforce]]),
|
||||
action: 'get',
|
||||
typeMap: authorizationResult.typeMap,
|
||||
auditCallback: (error) => {
|
||||
if (error) {
|
||||
this._securityExtension!.addAuditEvent({
|
||||
action: AuditAction.GET,
|
||||
savedObject: { type, id },
|
||||
error,
|
||||
});
|
||||
}
|
||||
// Audit event for success case is added separately below
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!isFoundGetResponse(body) ||
|
||||
|
@ -1949,25 +1910,20 @@ export class SavedObjectsRepository implements ISavedObjectsRepository {
|
|||
|
||||
const spacesToEnforce = new Set([SavedObjectsUtils.namespaceIdToString(namespace)]); // Always check/enforce authZ for the active space
|
||||
const existingNamespaces = preflightResult?.savedObjectNamespaces || [];
|
||||
const authorizationResult = await this._securityExtension?.checkAuthorization({
|
||||
|
||||
const authorizationResult = await this._securityExtension?.performAuthorization({
|
||||
actions: new Set(['update']),
|
||||
types: new Set([type]),
|
||||
spaces: new Set([...spacesToEnforce, ...existingNamespaces]), // existing namespaces are included so we can later redact if necessary
|
||||
actions: new Set(['update']),
|
||||
enforceMap: new Map([[type, spacesToEnforce]]),
|
||||
auditCallback: (error) =>
|
||||
this._securityExtension!.addAuditEvent({
|
||||
action: AuditAction.UPDATE,
|
||||
savedObject: { type, id },
|
||||
error,
|
||||
...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the update/upsert operation has not occurred yet
|
||||
}),
|
||||
});
|
||||
if (authorizationResult) {
|
||||
this._securityExtension!.enforceAuthorization({
|
||||
typesAndSpaces: new Map([[type, spacesToEnforce]]),
|
||||
action: 'update',
|
||||
typeMap: authorizationResult.typeMap,
|
||||
auditCallback: (error) =>
|
||||
this._securityExtension!.addAuditEvent({
|
||||
action: AuditAction.UPDATE,
|
||||
savedObject: { type, id },
|
||||
error,
|
||||
...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the update/upsert operation has not occurred yet
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
preflightResult?.checkResult === 'found_outside_namespace' ||
|
||||
|
@ -2236,28 +2192,22 @@ export class SavedObjectsRepository implements ISavedObjectsRepository {
|
|||
}
|
||||
}
|
||||
|
||||
const authorizationResult = await this._securityExtension?.checkAuthorization({
|
||||
const authorizationResult = await this._securityExtension?.performAuthorization({
|
||||
actions: new Set(['bulk_update']),
|
||||
types: new Set(typesAndSpaces.keys()),
|
||||
spaces: spacesToAuthorize,
|
||||
actions: new Set(['bulk_update']),
|
||||
enforceMap: typesAndSpaces,
|
||||
auditCallback: (error) => {
|
||||
for (const { value } of validObjects) {
|
||||
this._securityExtension!.addAuditEvent({
|
||||
action: AuditAction.UPDATE,
|
||||
savedObject: { type: value.type, id: value.id },
|
||||
error,
|
||||
...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the update operation has not occurred yet
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
if (authorizationResult) {
|
||||
this._securityExtension!.enforceAuthorization({
|
||||
typesAndSpaces,
|
||||
action: 'bulk_update',
|
||||
typeMap: authorizationResult.typeMap,
|
||||
auditCallback: (error) => {
|
||||
for (const { value } of validObjects) {
|
||||
this._securityExtension!.addAuditEvent({
|
||||
action: AuditAction.UPDATE,
|
||||
savedObject: { type: value.type, id: value.id },
|
||||
error,
|
||||
...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the update operation has not occurred yet
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let bulkUpdateRequestIndexCounter = 0;
|
||||
const bulkUpdateParams: object[] = [];
|
||||
|
@ -2416,25 +2366,19 @@ export class SavedObjectsRepository implements ISavedObjectsRepository {
|
|||
// TODO: Improve authorization and auditing (https://github.com/elastic/kibana/issues/135259)
|
||||
|
||||
const spaces = new Set([SavedObjectsUtils.namespaceIdToString(namespace)]); // Always check/enforce authZ for the active space
|
||||
const authorizationResult = await this._securityExtension?.checkAuthorization({
|
||||
await this._securityExtension?.performAuthorization({
|
||||
actions: new Set(['delete']),
|
||||
types: new Set([type]),
|
||||
spaces,
|
||||
actions: new Set(['delete']),
|
||||
enforceMap: new Map([[type, spaces]]),
|
||||
auditCallback: (error) =>
|
||||
this._securityExtension!.addAuditEvent({
|
||||
action: AuditAction.REMOVE_REFERENCES,
|
||||
savedObject: { type, id },
|
||||
error,
|
||||
...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the updateByQuery operation has not occurred yet
|
||||
}),
|
||||
});
|
||||
if (authorizationResult) {
|
||||
this._securityExtension!.enforceAuthorization({
|
||||
typesAndSpaces: new Map([[type, spaces]]),
|
||||
action: 'delete',
|
||||
typeMap: authorizationResult.typeMap,
|
||||
auditCallback: (error) =>
|
||||
this._securityExtension!.addAuditEvent({
|
||||
action: AuditAction.REMOVE_REFERENCES,
|
||||
savedObject: { type, id },
|
||||
error,
|
||||
...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the updateByQuery operation has not occurred yet
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const allTypes = this._registry.getAllTypes().map((t) => t.name);
|
||||
|
||||
|
@ -2707,10 +2651,10 @@ export class SavedObjectsRepository implements ISavedObjectsRepository {
|
|||
|
||||
if (!disableExtensions && this._securityExtension) {
|
||||
const spaces = new Set(namespaces);
|
||||
const preAuthorizationResult = await this._securityExtension.checkAuthorization({
|
||||
const preAuthorizationResult = await this._securityExtension?.performAuthorization({
|
||||
actions: new Set(['open_point_in_time']),
|
||||
types: new Set(types),
|
||||
spaces,
|
||||
actions: new Set(['open_point_in_time']),
|
||||
});
|
||||
if (preAuthorizationResult.status === 'unauthorized') {
|
||||
// If the user is unauthorized to find *anything* they requested, return an empty response
|
||||
|
|
|
@ -20,6 +20,8 @@ import type { SavedObjectsUpdateObjectsSpacesObject } from '@kbn/core-saved-obje
|
|||
import {
|
||||
SavedObjectsErrorHelpers,
|
||||
ALL_NAMESPACES_STRING,
|
||||
setsAreEqual,
|
||||
setMapsAreEqual,
|
||||
} from '@kbn/core-saved-objects-utils-server';
|
||||
import { SavedObjectsSerializer } from '@kbn/core-saved-objects-base-server-internal';
|
||||
import { typeRegistryMock } from '@kbn/core-saved-objects-base-server-mocks';
|
||||
|
@ -27,15 +29,10 @@ import type { UpdateObjectsSpacesParams } from './update_objects_spaces';
|
|||
import { updateObjectsSpaces } from './update_objects_spaces';
|
||||
import { AuditAction, type ISavedObjectsSecurityExtension } from '@kbn/core-saved-objects-server';
|
||||
import {
|
||||
authMap,
|
||||
checkAuthError,
|
||||
enforceError,
|
||||
typeMapsAreEqual,
|
||||
setsAreEqual,
|
||||
setupCheckAuthorized,
|
||||
setupCheckUnauthorized,
|
||||
setupEnforceFailure,
|
||||
setupEnforceSuccess,
|
||||
setupPerformAuthFullyAuthorized,
|
||||
setupPerformAuthEnforceFailure,
|
||||
setupRedactPassthrough,
|
||||
} from '../test_helpers/repository.test.common';
|
||||
import { savedObjectsExtensionsMock } from '../mocks/saved_objects_extensions.mock';
|
||||
|
@ -654,6 +651,11 @@ describe('#updateObjectsSpaces', () => {
|
|||
let mockSecurityExt: jest.Mocked<ISavedObjectsSecurityExtension>;
|
||||
let params: UpdateObjectsSpacesParams;
|
||||
|
||||
afterEach(() => {
|
||||
mockSecurityExt.performAuthorization.mockClear();
|
||||
mockSecurityExt.redactNamespaces.mockClear();
|
||||
});
|
||||
|
||||
describe(`errors`, () => {
|
||||
beforeEach(() => {
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' };
|
||||
|
@ -666,8 +668,7 @@ describe('#updateObjectsSpaces', () => {
|
|||
});
|
||||
|
||||
test(`propagates error from es client bulk get`, async () => {
|
||||
setupCheckAuthorized(mockSecurityExt);
|
||||
setupEnforceSuccess(mockSecurityExt);
|
||||
setupPerformAuthFullyAuthorized(mockSecurityExt);
|
||||
setupRedactPassthrough(mockSecurityExt);
|
||||
|
||||
const error = SavedObjectsErrorHelpers.createBadRequestError('OOPS!');
|
||||
|
@ -681,30 +682,25 @@ describe('#updateObjectsSpaces', () => {
|
|||
await expect(updateObjectsSpaces(params)).rejects.toThrow(error);
|
||||
});
|
||||
|
||||
test(`propagates decorated error when checkAuthorization rejects promise`, async () => {
|
||||
mockSecurityExt.checkAuthorization.mockRejectedValueOnce(checkAuthError);
|
||||
test(`propagates decorated error when performAuthorization rejects promise`, async () => {
|
||||
mockSecurityExt.performAuthorization.mockRejectedValueOnce(checkAuthError);
|
||||
|
||||
await expect(updateObjectsSpaces(params)).rejects.toThrow(checkAuthError);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.enforceAuthorization).not.toHaveBeenCalled();
|
||||
expect(mockSecurityExt.performAuthorization).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test(`propagates decorated error when unauthorized`, async () => {
|
||||
setupCheckUnauthorized(mockSecurityExt);
|
||||
setupEnforceFailure(mockSecurityExt);
|
||||
setupPerformAuthEnforceFailure(mockSecurityExt);
|
||||
|
||||
await expect(updateObjectsSpaces(params)).rejects.toThrow(enforceError);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.performAuthorization).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test(`adds audit event when not unauthorized`, async () => {
|
||||
setupCheckUnauthorized(mockSecurityExt);
|
||||
setupEnforceFailure(mockSecurityExt);
|
||||
setupPerformAuthEnforceFailure(mockSecurityExt);
|
||||
|
||||
await expect(updateObjectsSpaces(params)).rejects.toThrow(enforceError);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.performAuthorization).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({
|
||||
|
@ -717,8 +713,7 @@ describe('#updateObjectsSpaces', () => {
|
|||
});
|
||||
|
||||
test(`returns error from es client bulk operation`, async () => {
|
||||
setupCheckAuthorized(mockSecurityExt);
|
||||
setupEnforceSuccess(mockSecurityExt);
|
||||
setupPerformAuthFullyAuthorized(mockSecurityExt);
|
||||
setupRedactPassthrough(mockSecurityExt);
|
||||
|
||||
mockGetBulkOperationError.mockReset();
|
||||
|
@ -761,51 +756,37 @@ describe('#updateObjectsSpaces', () => {
|
|||
{ found: true, namespaces: [EXISTING_SPACE] } // result for obj4 -- will be updated to remove EXISTING_SPACE and add otherSpace
|
||||
);
|
||||
mockBulkResults({ error: false }, { error: false }, { error: false }); // results for obj2, obj3, and obj4
|
||||
setupCheckAuthorized(mockSecurityExt);
|
||||
setupEnforceSuccess(mockSecurityExt);
|
||||
setupPerformAuthFullyAuthorized(mockSecurityExt);
|
||||
setupRedactPassthrough(mockSecurityExt);
|
||||
});
|
||||
|
||||
test(`calls checkAuthorization with type, actions, and namespaces`, async () => {
|
||||
test(`calls performAuthorization with correct actions, types, spaces, and enforce map`, async () => {
|
||||
await updateObjectsSpaces(params);
|
||||
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.performAuthorization).toHaveBeenCalledTimes(1);
|
||||
const expectedActions = new Set(['share_to_space']);
|
||||
const expectedSpaces = new Set([defaultSpace, otherSpace, EXISTING_SPACE]);
|
||||
const expectedTypes = new Set([SHAREABLE_OBJ_TYPE]);
|
||||
const expectedEnforceMap = new Map<string, Set<string>>();
|
||||
expectedEnforceMap.set(
|
||||
SHAREABLE_OBJ_TYPE,
|
||||
new Set([defaultSpace, otherSpace, EXISTING_SPACE])
|
||||
);
|
||||
|
||||
const {
|
||||
actions: actualActions,
|
||||
spaces: actualSpaces,
|
||||
types: actualTypes,
|
||||
} = mockSecurityExt.checkAuthorization.mock.calls[0][0];
|
||||
enforceMap: actualEnforceMap,
|
||||
options: actualOptions,
|
||||
} = mockSecurityExt.performAuthorization.mock.calls[0][0];
|
||||
|
||||
expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy();
|
||||
expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy();
|
||||
expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy();
|
||||
});
|
||||
|
||||
test(`calls enforceAuthorization with action, type map, and auth map`, async () => {
|
||||
await updateObjectsSpaces(params);
|
||||
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'share_to_space',
|
||||
})
|
||||
);
|
||||
const expectedTypesAndSpaces = new Map([
|
||||
[SHAREABLE_OBJ_TYPE, new Set([defaultSpace, EXISTING_SPACE, otherSpace])],
|
||||
]);
|
||||
|
||||
const { typesAndSpaces: actualTypesAndSpaces, typeMap: actualTypeMap } =
|
||||
mockSecurityExt.enforceAuthorization.mock.calls[0][0];
|
||||
|
||||
expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy();
|
||||
expect(actualTypeMap).toBe(authMap);
|
||||
expect(setMapsAreEqual(actualEnforceMap, expectedEnforceMap)).toBeTruthy();
|
||||
expect(actualOptions).toEqual(expect.objectContaining({ allowGlobalResource: true }));
|
||||
});
|
||||
|
||||
test(`adds audit event per object when successful`, async () => {
|
||||
|
@ -844,55 +825,72 @@ describe('#updateObjectsSpaces', () => {
|
|||
{ found: true, namespaces: [EXISTING_SPACE] } // result for obj4 -- will be updated to remove EXISTING_SPACE and add otherSpace
|
||||
);
|
||||
mockBulkResults({ error: false }, { error: false }, { error: false }); // results for obj2, obj3, and obj4
|
||||
setupCheckAuthorized(mockSecurityExt);
|
||||
setupEnforceSuccess(mockSecurityExt);
|
||||
setupPerformAuthFullyAuthorized(mockSecurityExt);
|
||||
setupRedactPassthrough(mockSecurityExt);
|
||||
};
|
||||
|
||||
test(`calls checkAuthorization with '*' when spacesToAdd includes '*'`, async () => {
|
||||
test(`calls performAuthorization with '*' when spacesToAdd includes '*'`, async () => {
|
||||
const spacesToAdd = ['*'];
|
||||
const spacesToRemove = [otherSpace];
|
||||
setupForAllSpaces(spacesToAdd, spacesToRemove);
|
||||
await updateObjectsSpaces(params);
|
||||
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.performAuthorization).toHaveBeenCalledTimes(1);
|
||||
const expectedActions = new Set(['share_to_space']);
|
||||
const expectedSpaces = new Set(['*', defaultSpace, otherSpace, EXISTING_SPACE]);
|
||||
const expectedTypes = new Set([SHAREABLE_OBJ_TYPE]);
|
||||
const expectedEnforceMap = new Map<string, Set<string>>();
|
||||
expectedEnforceMap.set(
|
||||
SHAREABLE_OBJ_TYPE,
|
||||
new Set([defaultSpace, otherSpace, ...spacesToAdd])
|
||||
);
|
||||
|
||||
const {
|
||||
actions: actualActions,
|
||||
spaces: actualSpaces,
|
||||
types: actualTypes,
|
||||
} = mockSecurityExt.checkAuthorization.mock.calls[0][0];
|
||||
enforceMap: actualEnforceMap,
|
||||
options: actualOptions,
|
||||
} = mockSecurityExt.performAuthorization.mock.calls[0][0];
|
||||
|
||||
expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy();
|
||||
expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy();
|
||||
expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy();
|
||||
expect(setMapsAreEqual(actualEnforceMap, expectedEnforceMap)).toBeTruthy();
|
||||
expect(actualOptions).toEqual(expect.objectContaining({ allowGlobalResource: true }));
|
||||
});
|
||||
|
||||
test(`calls checkAuthorization with '*' when spacesToRemove includes '*'`, async () => {
|
||||
test(`calls performAuthorization with '*' when spacesToRemove includes '*'`, async () => {
|
||||
const spacesToAdd = [otherSpace];
|
||||
const spacesToRemove = ['*'];
|
||||
setupForAllSpaces(spacesToAdd, spacesToRemove);
|
||||
await updateObjectsSpaces(params);
|
||||
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.performAuthorization).toHaveBeenCalledTimes(1);
|
||||
const expectedActions = new Set(['share_to_space']);
|
||||
const expectedSpaces = new Set(['*', defaultSpace, otherSpace, EXISTING_SPACE]);
|
||||
const expectedTypes = new Set([SHAREABLE_OBJ_TYPE]);
|
||||
const expectedEnforceMap = new Map<string, Set<string>>();
|
||||
expectedEnforceMap.set(
|
||||
SHAREABLE_OBJ_TYPE,
|
||||
new Set([defaultSpace, otherSpace, ...spacesToRemove])
|
||||
);
|
||||
|
||||
const {
|
||||
actions: actualActions,
|
||||
spaces: actualSpaces,
|
||||
types: actualTypes,
|
||||
} = mockSecurityExt.checkAuthorization.mock.calls[0][0];
|
||||
enforceMap: actualEnforceMap,
|
||||
options: actualOptions,
|
||||
} = mockSecurityExt.performAuthorization.mock.calls[0][0];
|
||||
|
||||
expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy();
|
||||
expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy();
|
||||
expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy();
|
||||
expect(setMapsAreEqual(actualEnforceMap, expectedEnforceMap)).toBeTruthy();
|
||||
expect(actualOptions).toEqual(expect.objectContaining({ allowGlobalResource: true }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -204,33 +204,27 @@ export async function updateObjectsSpaces({
|
|||
}
|
||||
}
|
||||
|
||||
const authorizationResult = await securityExtension?.checkAuthorization({
|
||||
types: new Set(typesAndSpaces.keys()),
|
||||
spaces: spacesToAuthorize,
|
||||
actions: new Set(['share_to_space']),
|
||||
const authorizationResult = await securityExtension?.performAuthorization({
|
||||
// If a user tries to share/unshare an object to/from '*', they need to have 'share_to_space' privileges for the Global Resource (e.g.,
|
||||
// All privileges for All Spaces).
|
||||
actions: new Set(['share_to_space']),
|
||||
types: new Set(typesAndSpaces.keys()),
|
||||
spaces: spacesToAuthorize,
|
||||
enforceMap: typesAndSpaces,
|
||||
auditCallback: (error) => {
|
||||
for (const { value } of validObjects) {
|
||||
securityExtension!.addAuditEvent({
|
||||
action: AuditAction.UPDATE_OBJECTS_SPACES,
|
||||
savedObject: { type: value.type, id: value.id },
|
||||
addToSpaces,
|
||||
deleteFromSpaces,
|
||||
error,
|
||||
...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the update operation has not occurred yet
|
||||
});
|
||||
}
|
||||
},
|
||||
options: { allowGlobalResource: true },
|
||||
});
|
||||
if (authorizationResult) {
|
||||
securityExtension!.enforceAuthorization({
|
||||
typesAndSpaces,
|
||||
action: 'share_to_space',
|
||||
typeMap: authorizationResult.typeMap,
|
||||
auditCallback: (error) => {
|
||||
for (const { value } of validObjects) {
|
||||
securityExtension!.addAuditEvent({
|
||||
action: AuditAction.UPDATE_OBJECTS_SPACES,
|
||||
savedObject: { type: value.type, id: value.id },
|
||||
addToSpaces,
|
||||
deleteFromSpaces,
|
||||
error,
|
||||
...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the update operation has not occurred yet
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const time = new Date().toISOString();
|
||||
let bulkOperationRequestIndexCounter = 0;
|
||||
|
|
|
@ -20,7 +20,7 @@ const createEncryptionExtension = (): jest.Mocked<ISavedObjectsEncryptionExtensi
|
|||
});
|
||||
|
||||
const createSecurityExtension = (): jest.Mocked<ISavedObjectsSecurityExtension> => ({
|
||||
checkAuthorization: jest.fn(),
|
||||
performAuthorization: jest.fn(),
|
||||
enforceAuthorization: jest.fn(),
|
||||
addAuditEvent: jest.fn(),
|
||||
redactNamespaces: jest.fn(),
|
||||
|
|
|
@ -9,12 +9,12 @@
|
|||
import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { loggerMock } from '@kbn/logging-mocks';
|
||||
import { isEqual } from 'lodash';
|
||||
import { Payload } from 'elastic-apm-node';
|
||||
import {
|
||||
AuthorizationTypeEntry,
|
||||
EnforceAuthorizationParams,
|
||||
CheckAuthorizationResult,
|
||||
ISavedObjectsSecurityExtension,
|
||||
PerformAuthorizationParams,
|
||||
SavedObjectsMappingProperties,
|
||||
SavedObjectsRawDocSource,
|
||||
SavedObjectsType,
|
||||
|
@ -235,49 +235,47 @@ export const enforceError = SavedObjectsErrorHelpers.decorateForbiddenError(
|
|||
'User lacks privileges'
|
||||
);
|
||||
|
||||
export const setupCheckAuthorized = (
|
||||
export const setupPerformAuthFullyAuthorized = (
|
||||
mockSecurityExt: jest.Mocked<ISavedObjectsSecurityExtension>
|
||||
) => {
|
||||
mockSecurityExt.checkAuthorization.mockResolvedValue({
|
||||
status: 'fully_authorized',
|
||||
typeMap: authMap,
|
||||
});
|
||||
};
|
||||
|
||||
export const setupCheckPartiallyAuthorized = (
|
||||
mockSecurityExt: jest.Mocked<ISavedObjectsSecurityExtension>
|
||||
) => {
|
||||
mockSecurityExt.checkAuthorization.mockResolvedValue({
|
||||
status: 'partially_authorized',
|
||||
typeMap: authMap,
|
||||
});
|
||||
};
|
||||
|
||||
export const setupCheckUnauthorized = (
|
||||
mockSecurityExt: jest.Mocked<ISavedObjectsSecurityExtension>
|
||||
) => {
|
||||
mockSecurityExt.checkAuthorization.mockResolvedValue({
|
||||
status: 'unauthorized',
|
||||
typeMap: new Map([]),
|
||||
});
|
||||
};
|
||||
|
||||
export const setupEnforceSuccess = (
|
||||
mockSecurityExt: jest.Mocked<ISavedObjectsSecurityExtension>
|
||||
) => {
|
||||
mockSecurityExt.enforceAuthorization.mockImplementation(
|
||||
(params: EnforceAuthorizationParams<string>) => {
|
||||
mockSecurityExt.performAuthorization.mockImplementation(
|
||||
(params: PerformAuthorizationParams<string>): Promise<CheckAuthorizationResult<string>> => {
|
||||
const { auditCallback } = params;
|
||||
auditCallback?.(undefined);
|
||||
return Promise.resolve({ status: 'fully_authorized', typeMap: authMap });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const setupEnforceFailure = (
|
||||
export const setupPerformAuthPartiallyAuthorized = (
|
||||
mockSecurityExt: jest.Mocked<ISavedObjectsSecurityExtension>
|
||||
) => {
|
||||
mockSecurityExt.enforceAuthorization.mockImplementation(
|
||||
(params: EnforceAuthorizationParams<string>) => {
|
||||
mockSecurityExt.performAuthorization.mockImplementation(
|
||||
(params: PerformAuthorizationParams<string>): Promise<CheckAuthorizationResult<string>> => {
|
||||
const { auditCallback } = params;
|
||||
auditCallback?.(undefined);
|
||||
return Promise.resolve({ status: 'partially_authorized', typeMap: authMap });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const setupPerformAuthUnauthorized = (
|
||||
mockSecurityExt: jest.Mocked<ISavedObjectsSecurityExtension>
|
||||
) => {
|
||||
mockSecurityExt.performAuthorization.mockImplementation(
|
||||
(params: PerformAuthorizationParams<string>): Promise<CheckAuthorizationResult<string>> => {
|
||||
const { auditCallback } = params;
|
||||
auditCallback?.(undefined);
|
||||
return Promise.resolve({ status: 'unauthorized', typeMap: new Map([]) });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const setupPerformAuthEnforceFailure = (
|
||||
mockSecurityExt: jest.Mocked<ISavedObjectsSecurityExtension>
|
||||
) => {
|
||||
mockSecurityExt.performAuthorization.mockImplementation(
|
||||
(params: PerformAuthorizationParams<string>) => {
|
||||
const { auditCallback } = params;
|
||||
auditCallback?.(enforceError);
|
||||
throw enforceError;
|
||||
|
@ -850,27 +848,6 @@ export const getSuccess = async (
|
|||
return result;
|
||||
};
|
||||
|
||||
export function setsAreEqual<T>(setA: Set<T>, setB: Set<T>) {
|
||||
return isEqual(Array(setA).sort(), Array(setB).sort());
|
||||
}
|
||||
|
||||
export function typeMapsAreEqual(mapA: Map<string, Set<string>>, mapB: Map<string, Set<string>>) {
|
||||
return (
|
||||
mapA.size === mapB.size &&
|
||||
Array.from(mapA.keys()).every((key) => setsAreEqual(mapA.get(key)!, mapB.get(key)!))
|
||||
);
|
||||
}
|
||||
|
||||
export function namespaceMapsAreEqual(
|
||||
mapA: Map<string, string[] | undefined>,
|
||||
mapB: Map<string, string[] | undefined>
|
||||
) {
|
||||
return (
|
||||
mapA.size === mapB.size &&
|
||||
Array.from(mapA.keys()).every((key) => isEqual(mapA.get(key)?.sort(), mapB.get(key)?.sort()))
|
||||
);
|
||||
}
|
||||
|
||||
export const getMockEsBulkDeleteResponse = (
|
||||
registry: SavedObjectTypeRegistry,
|
||||
objects: TypeIdTuple[],
|
||||
|
|
|
@ -20,7 +20,7 @@ const createEncryptionExtension = (): jest.Mocked<ISavedObjectsEncryptionExtensi
|
|||
});
|
||||
|
||||
const createSecurityExtension = (): jest.Mocked<ISavedObjectsSecurityExtension> => ({
|
||||
checkAuthorization: jest.fn(),
|
||||
performAuthorization: jest.fn(),
|
||||
enforceAuthorization: jest.fn(),
|
||||
addAuditEvent: jest.fn(),
|
||||
redactNamespaces: jest.fn(),
|
||||
|
|
|
@ -72,6 +72,7 @@ export type {
|
|||
} from './src/extensions/encryption';
|
||||
export type {
|
||||
CheckAuthorizationParams,
|
||||
PerformAuthorizationParams,
|
||||
AuthorizationTypeEntry,
|
||||
AuthorizationTypeMap,
|
||||
CheckAuthorizationResult,
|
||||
|
|
|
@ -9,6 +9,43 @@
|
|||
import type { SavedObject } from '@kbn/core-saved-objects-common';
|
||||
import type { EcsEventOutcome } from '@kbn/ecs';
|
||||
|
||||
/**
|
||||
* The PerformAuthorizationParams interface contains settings for checking
|
||||
* & enforcing authorization via the ISavedObjectsSecurityExtension.
|
||||
*/
|
||||
export interface PerformAuthorizationParams<A extends string> {
|
||||
/**
|
||||
* A set of actions to check.
|
||||
*/
|
||||
actions: Set<A>;
|
||||
/**
|
||||
* A set of types to check.
|
||||
*/
|
||||
types: Set<string>;
|
||||
/**
|
||||
* A set of spaces to check (types to check comes from the typesAndSpaces map).
|
||||
*/
|
||||
spaces: Set<string>;
|
||||
/**
|
||||
* A map of types (key) to spaces (value) that will be affected by the action(s).
|
||||
* If undefined, enforce with be bypassed.
|
||||
*/
|
||||
enforceMap?: Map<string, Set<string>>;
|
||||
/**
|
||||
* A callback intended to handle adding audit events in
|
||||
* both error (unauthorized), or success (authorized)
|
||||
* cases
|
||||
*/
|
||||
auditCallback?: (error?: Error) => void;
|
||||
/**
|
||||
* Authorization options
|
||||
* allowGlobalResource - whether or not to allow global resources, false if options are undefined
|
||||
*/
|
||||
options?: {
|
||||
allowGlobalResource: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The CheckAuthorizationParams interface contains settings for checking
|
||||
* authorization via the ISavedObjectsSecurityExtension.
|
||||
|
@ -178,12 +215,12 @@ export interface RedactNamespacesParams<T, A extends string> {
|
|||
*/
|
||||
export interface ISavedObjectsSecurityExtension {
|
||||
/**
|
||||
* Checks authorization of actions on specified types in specified spaces.
|
||||
* @param params - types, spaces, and actions to check
|
||||
* Performs authorization (check & enforce) of actions on specified types in specified spaces.
|
||||
* @param params - actions, types & spaces map, audit callback, options (enforce bypassed if enforce map is undefined)
|
||||
* @returns CheckAuthorizationResult - the resulting authorization level and authorization map
|
||||
*/
|
||||
checkAuthorization: <T extends string>(
|
||||
params: CheckAuthorizationParams<T>
|
||||
performAuthorization: <T extends string>(
|
||||
params: PerformAuthorizationParams<T>
|
||||
) => Promise<CheckAuthorizationResult<T>>;
|
||||
|
||||
/**
|
||||
|
|
|
@ -15,3 +15,5 @@ export {
|
|||
FIND_DEFAULT_PAGE,
|
||||
FIND_DEFAULT_PER_PAGE,
|
||||
} from './src/saved_objects_utils';
|
||||
|
||||
export { setsAreEqual, arrayMapsAreEqual, setMapsAreEqual } from './src/saved_objects_test_utils';
|
||||
|
|
|
@ -0,0 +1,160 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { arrayMapsAreEqual, setMapsAreEqual, setsAreEqual } from './saved_objects_test_utils';
|
||||
|
||||
describe('savedObjects/testUtils', () => {
|
||||
describe('#setsAreEqual', () => {
|
||||
const setA = new Set(['1', '2', '3']);
|
||||
const setB = new Set(['1', '2']);
|
||||
const setC = new Set(['1', '3', '4']);
|
||||
const setD = new Set(['2', '3', '1']);
|
||||
|
||||
describe('inequal', () => {
|
||||
it('should return false if the sets are not the same size', () => {
|
||||
expect(setsAreEqual(setA, setB)).toBeFalsy();
|
||||
expect(setsAreEqual(setB, setA)).toBeFalsy();
|
||||
expect(setsAreEqual(setA, new Set())).toBeFalsy();
|
||||
expect(setsAreEqual(new Set(), setA)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return false if the sets do not have the same values', () => {
|
||||
expect(setsAreEqual(setA, setC)).toBeFalsy();
|
||||
expect(setsAreEqual(setC, setA)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('equal', () => {
|
||||
it('should return true if the sets are exactly the same', () => {
|
||||
expect(setsAreEqual(setA, setD)).toBeTruthy();
|
||||
expect(setsAreEqual(setD, setA)).toBeTruthy();
|
||||
expect(setsAreEqual(new Set(), new Set())).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#arrayMapsAreEqual', () => {
|
||||
const mapA = new Map<string, string[]>();
|
||||
mapA.set('a', ['1', '2', '3']);
|
||||
mapA.set('b', ['1', '2']);
|
||||
|
||||
const mapB = new Map<string, string[]>();
|
||||
mapB.set('a', ['1', '2', '3']);
|
||||
|
||||
const mapC = new Map<string, string[]>();
|
||||
mapC.set('a', ['1', '2', '3']);
|
||||
mapC.set('c', ['1', '2']);
|
||||
|
||||
const mapD = new Map<string, string[]>();
|
||||
mapD.set('a', ['1', '2', '3']);
|
||||
mapD.set('b', ['1', '3']);
|
||||
|
||||
const mapE = new Map<string, string[]>();
|
||||
mapE.set('b', ['2', '1']);
|
||||
mapE.set('a', ['3', '1', '2']);
|
||||
|
||||
const mapF = new Map<string, string[] | undefined>();
|
||||
mapF.set('a', ['1', '2', '3']);
|
||||
mapF.set('b', undefined);
|
||||
|
||||
const mapG = new Map<string, string[] | undefined>();
|
||||
mapG.set('b', undefined);
|
||||
mapG.set('a', ['3', '1', '2']);
|
||||
|
||||
const mapH = new Map<string, string[] | undefined>();
|
||||
mapF.set('a', ['1', '2', '3']);
|
||||
mapF.set('b', []);
|
||||
|
||||
const mapI = new Map<string, string[] | undefined>();
|
||||
mapG.set('b', []);
|
||||
mapG.set('a', ['3', '1', '2']);
|
||||
|
||||
describe('inequal', () => {
|
||||
it('should return false if the maps are not the same size', () => {
|
||||
expect(arrayMapsAreEqual(mapA, mapB)).toBeFalsy();
|
||||
expect(arrayMapsAreEqual(mapB, mapA)).toBeFalsy();
|
||||
expect(arrayMapsAreEqual(mapA, new Map())).toBeFalsy();
|
||||
expect(arrayMapsAreEqual(new Map(), mapA)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return false if the maps do not have the same keys', () => {
|
||||
expect(arrayMapsAreEqual(mapA, mapC)).toBeFalsy();
|
||||
expect(arrayMapsAreEqual(mapC, mapA)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return false if the maps do not have the same values', () => {
|
||||
expect(arrayMapsAreEqual(mapA, mapD)).toBeFalsy();
|
||||
expect(arrayMapsAreEqual(mapD, mapA)).toBeFalsy();
|
||||
expect(arrayMapsAreEqual(mapA, mapF)).toBeFalsy();
|
||||
expect(arrayMapsAreEqual(mapF, mapA)).toBeFalsy();
|
||||
expect(arrayMapsAreEqual(mapA, mapH)).toBeFalsy();
|
||||
expect(arrayMapsAreEqual(mapH, mapA)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('equal', () => {
|
||||
it('should return true if the maps are exactly the same', () => {
|
||||
expect(arrayMapsAreEqual(mapA, mapE)).toBeTruthy();
|
||||
expect(arrayMapsAreEqual(mapE, mapA)).toBeTruthy();
|
||||
expect(arrayMapsAreEqual(new Map(), new Map())).toBeTruthy();
|
||||
expect(arrayMapsAreEqual(mapF, mapG)).toBeTruthy();
|
||||
expect(arrayMapsAreEqual(mapG, mapF)).toBeTruthy();
|
||||
expect(arrayMapsAreEqual(mapH, mapI)).toBeTruthy();
|
||||
expect(arrayMapsAreEqual(mapI, mapH)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#setMapsAreEqual', () => {
|
||||
const mapA = new Map<string, Set<string>>();
|
||||
mapA.set('a', new Set(['1', '2', '3']));
|
||||
mapA.set('b', new Set(['1', '2']));
|
||||
|
||||
const mapB = new Map<string, Set<string>>();
|
||||
mapB.set('a', new Set(['1', '2', '3']));
|
||||
|
||||
const mapC = new Map<string, Set<string>>();
|
||||
mapC.set('a', new Set(['1', '2', '3']));
|
||||
mapC.set('c', new Set(['1', '2']));
|
||||
|
||||
const mapD = new Map<string, Set<string>>();
|
||||
mapD.set('a', new Set(['1', '2', '3']));
|
||||
mapD.set('b', new Set(['1', '3']));
|
||||
|
||||
const mapE = new Map<string, Set<string>>();
|
||||
mapE.set('b', new Set(['2', '1']));
|
||||
mapE.set('a', new Set(['3', '1', '2']));
|
||||
|
||||
describe('inequal', () => {
|
||||
it('should return false if the maps are not the same size', () => {
|
||||
expect(setMapsAreEqual(mapA, mapB)).toBeFalsy();
|
||||
expect(setMapsAreEqual(mapB, mapA)).toBeFalsy();
|
||||
expect(setMapsAreEqual(mapA, new Map())).toBeFalsy();
|
||||
expect(setMapsAreEqual(new Map(), mapA)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return false if the maps do not have the same keys', () => {
|
||||
expect(setMapsAreEqual(mapA, mapC)).toBeFalsy();
|
||||
expect(setMapsAreEqual(mapC, mapA)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return false if the maps do not have the same values', () => {
|
||||
expect(setMapsAreEqual(mapA, mapD)).toBeFalsy();
|
||||
expect(setMapsAreEqual(mapD, mapA)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('equal', () => {
|
||||
it('should return true if the maps are exactly the same', () => {
|
||||
expect(setMapsAreEqual(mapA, mapE)).toBeTruthy();
|
||||
expect(setMapsAreEqual(mapE, mapA)).toBeTruthy();
|
||||
expect(setMapsAreEqual(new Map(), new Map())).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
/**
|
||||
* Determines if a given Set is equal to another given Set. Set types must be the same, and comparable.
|
||||
*
|
||||
* @param setA The first Set to compare
|
||||
* @param setB The second Set to compare
|
||||
* @returns {boolean} True if Set A is equal to Set B
|
||||
*/
|
||||
export function setsAreEqual<T>(setA: Set<T>, setB: Set<T>) {
|
||||
if (setA.size !== setB.size) return false;
|
||||
|
||||
for (const element of setA) {
|
||||
if (!setB.has(element)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a given map of arrays is equal to another given map of arrays.
|
||||
* Used for comparing namespace maps in saved object repo/security extension tests.
|
||||
*
|
||||
* @param mapA The first map to compare
|
||||
* @param mapB The second map to compare
|
||||
* @returns {boolean} True if map A is equal to map B
|
||||
*/
|
||||
export function arrayMapsAreEqual<T>(mapA: Map<T, T[] | undefined>, mapB: Map<T, T[] | undefined>) {
|
||||
if (mapA?.size !== mapB?.size) return false;
|
||||
|
||||
for (const [key, valueA] of mapA!) {
|
||||
const valueB = mapB?.get(key);
|
||||
if (valueA?.length !== valueB?.length) return false;
|
||||
if (!isEqual(valueA?.sort(), valueB?.sort())) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a given Map of Sets is equal to another given Map of Sets.
|
||||
* Used for comparing typeMaps and enforceMaps in saved object repo/security extension tests.
|
||||
*
|
||||
* @param mapA The first map to compare
|
||||
* @param mapB The second map to compare
|
||||
* @returns {boolean} True if map A is equal to map B
|
||||
*/
|
||||
export function setMapsAreEqual<T>(
|
||||
mapA: Map<T, Set<T>> | undefined,
|
||||
mapB: Map<T, Set<T>> | undefined
|
||||
) {
|
||||
if (mapA?.size !== mapB?.size) return false;
|
||||
|
||||
for (const [key, valueA] of mapA!) {
|
||||
const valueB = mapB?.get(key);
|
||||
if (!valueB || !setsAreEqual(valueA, valueB)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
|
@ -356,7 +356,7 @@ export type {
|
|||
SavedObjectsRequestHandlerContext,
|
||||
EncryptedObjectDescriptor,
|
||||
ISavedObjectsEncryptionExtension,
|
||||
CheckAuthorizationParams,
|
||||
PerformAuthorizationParams,
|
||||
AuthorizationTypeEntry,
|
||||
AuthorizationTypeMap,
|
||||
CheckAuthorizationResult,
|
||||
|
|
|
@ -34,234 +34,6 @@ function setup() {
|
|||
return { actions, auditLogger, errors, checkPrivileges, securityExtension };
|
||||
}
|
||||
|
||||
describe('#checkAuthorization', () => {
|
||||
// These arguments are used for all unit tests below
|
||||
const types = new Set(['a', 'b', 'c']);
|
||||
const spaces = new Set(['x', 'y']);
|
||||
const actions = new Set(['foo', 'bar']);
|
||||
|
||||
const fullyAuthorizedCheckPrivilegesResponse = {
|
||||
hasAllRequested: true,
|
||||
privileges: {
|
||||
kibana: [
|
||||
{ privilege: 'mock-saved_object:a/foo', authorized: true },
|
||||
{ privilege: 'mock-saved_object:a/bar', authorized: true },
|
||||
{ privilege: 'login:', authorized: true },
|
||||
{ resource: 'x', privilege: 'mock-saved_object:b/foo', authorized: true },
|
||||
{ resource: 'x', privilege: 'mock-saved_object:b/bar', authorized: true },
|
||||
{ resource: 'x', privilege: 'mock-saved_object:c/foo', authorized: true },
|
||||
{ resource: 'x', privilege: 'mock-saved_object:c/bar', authorized: true },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:b/foo', authorized: true },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:b/bar', authorized: true },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:c/foo', authorized: true },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:c/bar', authorized: true },
|
||||
],
|
||||
},
|
||||
} as CheckPrivilegesResponse;
|
||||
|
||||
test('calls checkPrivileges with expected privilege actions and namespaces', async () => {
|
||||
const { securityExtension, checkPrivileges } = setup();
|
||||
checkPrivileges.mockResolvedValue(fullyAuthorizedCheckPrivilegesResponse); // Return any well-formed response to avoid an unhandled error
|
||||
|
||||
await securityExtension.checkAuthorization({ types, spaces, actions });
|
||||
expect(checkPrivileges).toHaveBeenCalledWith(
|
||||
[
|
||||
'mock-saved_object:a/foo',
|
||||
'mock-saved_object:a/bar',
|
||||
'mock-saved_object:b/foo',
|
||||
'mock-saved_object:b/bar',
|
||||
'mock-saved_object:c/foo',
|
||||
'mock-saved_object:c/bar',
|
||||
'login:',
|
||||
],
|
||||
[...spaces]
|
||||
);
|
||||
});
|
||||
|
||||
test('throws an error when `types` is empty', async () => {
|
||||
const { securityExtension, checkPrivileges } = setup();
|
||||
|
||||
await expect(
|
||||
securityExtension.checkAuthorization({ types: new Set(), spaces, actions })
|
||||
).rejects.toThrowError('No types specified for authorization check');
|
||||
expect(checkPrivileges).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('throws an error when `spaces` is empty', async () => {
|
||||
const { securityExtension, checkPrivileges } = setup();
|
||||
|
||||
await expect(
|
||||
securityExtension.checkAuthorization({ types, spaces: new Set(), actions })
|
||||
).rejects.toThrowError('No spaces specified for authorization check');
|
||||
expect(checkPrivileges).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('throws an error when `actions` is empty', async () => {
|
||||
const { securityExtension, checkPrivileges } = setup();
|
||||
|
||||
await expect(
|
||||
securityExtension.checkAuthorization({ types, spaces, actions: new Set([]) })
|
||||
).rejects.toThrowError('No actions specified for authorization check');
|
||||
expect(checkPrivileges).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('throws an error when privilege check fails', async () => {
|
||||
const { securityExtension, checkPrivileges } = setup();
|
||||
checkPrivileges.mockRejectedValue(new Error('Oh no!'));
|
||||
|
||||
await expect(
|
||||
securityExtension.checkAuthorization({ types, spaces, actions })
|
||||
).rejects.toThrowError('Oh no!');
|
||||
});
|
||||
|
||||
test('fully authorized', async () => {
|
||||
const { securityExtension, checkPrivileges } = setup();
|
||||
checkPrivileges.mockResolvedValue(fullyAuthorizedCheckPrivilegesResponse);
|
||||
|
||||
const result = await securityExtension.checkAuthorization({ types, spaces, actions });
|
||||
expect(result).toEqual({
|
||||
status: 'fully_authorized',
|
||||
typeMap: new Map()
|
||||
.set('a', {
|
||||
foo: { isGloballyAuthorized: true, authorizedSpaces: [] },
|
||||
bar: { isGloballyAuthorized: true, authorizedSpaces: [] },
|
||||
// Technically, 'login:' is not a saved object action, it is a Kibana privilege -- however, we include it in the `typeMap` results
|
||||
// for ease of use with the `redactNamespaces` function. The user is never actually authorized to "login" for a given object type,
|
||||
// they are authorized to log in on a per-space basis, and this is applied to each object type in the typeMap result accordingly.
|
||||
['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] },
|
||||
})
|
||||
.set('b', {
|
||||
foo: { authorizedSpaces: ['x', 'y'] },
|
||||
bar: { authorizedSpaces: ['x', 'y'] },
|
||||
['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] },
|
||||
})
|
||||
.set('c', {
|
||||
foo: { authorizedSpaces: ['x', 'y'] },
|
||||
bar: { authorizedSpaces: ['x', 'y'] },
|
||||
['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test('partially authorized', async () => {
|
||||
const { securityExtension, checkPrivileges } = setup();
|
||||
checkPrivileges.mockResolvedValue({
|
||||
hasAllRequested: false,
|
||||
privileges: {
|
||||
kibana: [
|
||||
// For type 'a', the user is authorized to use 'foo' action but not 'bar' action (all spaces)
|
||||
// For type 'b', the user is authorized to use 'foo' action but not 'bar' action (both spaces)
|
||||
// For type 'c', the user is authorized to use both actions in space 'x' but not space 'y'
|
||||
{ privilege: 'mock-saved_object:a/foo', authorized: true },
|
||||
{ privilege: 'mock-saved_object:a/bar', authorized: false },
|
||||
{ privilege: 'mock-saved_object:a/bar', authorized: true }, // fail-secure check
|
||||
{ resource: 'x', privilege: 'mock-saved_object:b/foo', authorized: true },
|
||||
{ resource: 'x', privilege: 'mock-saved_object:b/bar', authorized: false },
|
||||
{ resource: 'x', privilege: 'mock-saved_object:c/foo', authorized: true },
|
||||
{ privilege: 'mock-saved_object:c/foo', authorized: false }, // inverse fail-secure check
|
||||
{ resource: 'x', privilege: 'mock-saved_object:c/bar', authorized: true },
|
||||
{ resource: 'x', privilege: 'login:', authorized: true },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:b/foo', authorized: true },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:b/bar', authorized: false },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:c/foo', authorized: false },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:c/bar', authorized: false },
|
||||
{ privilege: 'mock-saved_object:c/bar', authorized: true }, // fail-secure check
|
||||
{ resource: 'y', privilege: 'mock-saved_object:c/bar', authorized: true }, // fail-secure check
|
||||
{ resource: 'y', privilege: 'login:', authorized: true },
|
||||
// The fail-secure checks are a contrived scenario, as we *shouldn't* get both an unauthorized and authorized result for a given resource...
|
||||
// However, in case we do, we should fail-secure (authorized + unauthorized = unauthorized)
|
||||
],
|
||||
},
|
||||
} as CheckPrivilegesResponse);
|
||||
|
||||
const result = await securityExtension.checkAuthorization({ types, spaces, actions });
|
||||
expect(result).toEqual({
|
||||
status: 'partially_authorized',
|
||||
typeMap: new Map()
|
||||
.set('a', {
|
||||
foo: { isGloballyAuthorized: true, authorizedSpaces: [] },
|
||||
['login:']: { authorizedSpaces: ['x', 'y'] },
|
||||
})
|
||||
.set('b', {
|
||||
foo: { authorizedSpaces: ['x', 'y'] },
|
||||
['login:']: { authorizedSpaces: ['x', 'y'] },
|
||||
})
|
||||
.set('c', {
|
||||
foo: { authorizedSpaces: ['x'] },
|
||||
bar: { authorizedSpaces: ['x'] },
|
||||
['login:']: { authorizedSpaces: ['x', 'y'] },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test('unauthorized', async () => {
|
||||
const { securityExtension, checkPrivileges } = setup();
|
||||
checkPrivileges.mockResolvedValue({
|
||||
hasAllRequested: false,
|
||||
privileges: {
|
||||
kibana: [
|
||||
{ privilege: 'mock-saved_object:a/foo', authorized: false },
|
||||
{ privilege: 'mock-saved_object:a/bar', authorized: false },
|
||||
{ privilege: 'mock-saved_object:a/bar', authorized: true }, // fail-secure check
|
||||
{ resource: 'x', privilege: 'mock-saved_object:b/foo', authorized: false },
|
||||
{ resource: 'x', privilege: 'mock-saved_object:b/bar', authorized: false },
|
||||
{ resource: 'x', privilege: 'mock-saved_object:c/foo', authorized: false },
|
||||
{ resource: 'x', privilege: 'mock-saved_object:c/bar', authorized: false },
|
||||
{ resource: 'x', privilege: 'login:', authorized: false },
|
||||
{ resource: 'x', privilege: 'login:', authorized: true }, // fail-secure check
|
||||
{ resource: 'y', privilege: 'mock-saved_object:a/foo', authorized: false },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:a/bar', authorized: false },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:b/foo', authorized: false },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:b/bar', authorized: false },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:c/foo', authorized: false },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:c/bar', authorized: false },
|
||||
{ privilege: 'mock-saved_object:c/bar', authorized: true }, // fail-secure check
|
||||
{ resource: 'y', privilege: 'mock-saved_object:c/bar', authorized: true }, // fail-secure check
|
||||
{ resource: 'y', privilege: 'login:', authorized: true }, // should *not* result in a 'partially_authorized' status
|
||||
// The fail-secure checks are a contrived scenario, as we *shouldn't* get both an unauthorized and authorized result for a given resource...
|
||||
// However, in case we do, we should fail-secure (authorized + unauthorized = unauthorized)
|
||||
],
|
||||
},
|
||||
} as CheckPrivilegesResponse);
|
||||
|
||||
const result = await securityExtension.checkAuthorization({ types, spaces, actions });
|
||||
expect(result).toEqual({
|
||||
// The user is authorized to log into space Y, but they are not authorized to take any actions on any of the requested object types.
|
||||
// Therefore, the status is 'unauthorized'.
|
||||
status: 'unauthorized',
|
||||
typeMap: new Map()
|
||||
.set('a', { ['login:']: { authorizedSpaces: ['y'] } })
|
||||
.set('b', { ['login:']: { authorizedSpaces: ['y'] } })
|
||||
.set('c', { ['login:']: { authorizedSpaces: ['y'] } }),
|
||||
});
|
||||
});
|
||||
|
||||
test('conflicting privilege failsafe', async () => {
|
||||
const conflictingPrivilegesResponse = {
|
||||
hasAllRequested: true,
|
||||
privileges: {
|
||||
kibana: [
|
||||
// redundant conflicting privileges for space X, type B, action Foo
|
||||
{ resource: 'x', privilege: 'mock-saved_object:b/foo', authorized: true },
|
||||
{ resource: 'x', privilege: 'mock-saved_object:b/foo', authorized: false },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:b/foo', authorized: true },
|
||||
],
|
||||
},
|
||||
} as CheckPrivilegesResponse;
|
||||
|
||||
const { securityExtension, checkPrivileges } = setup();
|
||||
checkPrivileges.mockResolvedValue(conflictingPrivilegesResponse);
|
||||
|
||||
const result = await securityExtension.checkAuthorization({ types, spaces, actions });
|
||||
expect(result).toEqual({
|
||||
status: 'fully_authorized',
|
||||
typeMap: new Map().set('b', {
|
||||
foo: { authorizedSpaces: ['y'] }, // should NOT be authorized for conflicted privilege
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#enforceAuthorization', () => {
|
||||
test('fully authorized', () => {
|
||||
const { securityExtension } = setup();
|
||||
|
@ -367,6 +139,335 @@ describe('#enforceAuthorization', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('#performAuthorization', () => {
|
||||
describe('without enforce', () => {
|
||||
// These arguments are used for all unit tests below
|
||||
const types = new Set(['a', 'b', 'c']);
|
||||
const spaces = new Set(['x', 'y']);
|
||||
const actions = new Set(['foo', 'bar']);
|
||||
|
||||
const fullyAuthorizedCheckPrivilegesResponse = {
|
||||
hasAllRequested: true,
|
||||
privileges: {
|
||||
kibana: [
|
||||
{ privilege: 'mock-saved_object:a/foo', authorized: true },
|
||||
{ privilege: 'mock-saved_object:a/bar', authorized: true },
|
||||
{ privilege: 'login:', authorized: true },
|
||||
{ resource: 'x', privilege: 'mock-saved_object:b/foo', authorized: true },
|
||||
{ resource: 'x', privilege: 'mock-saved_object:b/bar', authorized: true },
|
||||
{ resource: 'x', privilege: 'mock-saved_object:c/foo', authorized: true },
|
||||
{ resource: 'x', privilege: 'mock-saved_object:c/bar', authorized: true },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:b/foo', authorized: true },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:b/bar', authorized: true },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:c/foo', authorized: true },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:c/bar', authorized: true },
|
||||
],
|
||||
},
|
||||
} as CheckPrivilegesResponse;
|
||||
test('calls performPrivileges with expected privilege actions and namespaces', async () => {
|
||||
const { securityExtension, checkPrivileges } = setup();
|
||||
checkPrivileges.mockResolvedValue(fullyAuthorizedCheckPrivilegesResponse); // Return any well-formed response to avoid an unhandled error
|
||||
|
||||
await securityExtension.performAuthorization({ types, spaces, actions });
|
||||
expect(checkPrivileges).toHaveBeenCalledWith(
|
||||
[
|
||||
'mock-saved_object:a/foo',
|
||||
'mock-saved_object:a/bar',
|
||||
'mock-saved_object:b/foo',
|
||||
'mock-saved_object:b/bar',
|
||||
'mock-saved_object:c/foo',
|
||||
'mock-saved_object:c/bar',
|
||||
'login:',
|
||||
],
|
||||
[...spaces]
|
||||
);
|
||||
});
|
||||
|
||||
test('throws an error when `types` is empty', async () => {
|
||||
const { securityExtension, checkPrivileges } = setup();
|
||||
|
||||
await expect(
|
||||
securityExtension.performAuthorization({ types: new Set(), spaces, actions })
|
||||
).rejects.toThrowError('No types specified for authorization check');
|
||||
expect(checkPrivileges).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('throws an error when `spaces` is empty', async () => {
|
||||
const { securityExtension, checkPrivileges } = setup();
|
||||
|
||||
await expect(
|
||||
securityExtension.performAuthorization({ types, spaces: new Set(), actions })
|
||||
).rejects.toThrowError('No spaces specified for authorization check');
|
||||
expect(checkPrivileges).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('throws an error when `actions` is empty', async () => {
|
||||
const { securityExtension, checkPrivileges } = setup();
|
||||
|
||||
await expect(
|
||||
securityExtension.performAuthorization({ types, spaces, actions: new Set([]) })
|
||||
).rejects.toThrowError('No actions specified for authorization check');
|
||||
expect(checkPrivileges).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('throws an error when privilege check fails', async () => {
|
||||
const { securityExtension, checkPrivileges } = setup();
|
||||
checkPrivileges.mockRejectedValue(new Error('Oh no!'));
|
||||
|
||||
await expect(
|
||||
securityExtension.performAuthorization({ types, spaces, actions })
|
||||
).rejects.toThrowError('Oh no!');
|
||||
});
|
||||
|
||||
test('fully authorized', async () => {
|
||||
const { securityExtension, checkPrivileges } = setup();
|
||||
checkPrivileges.mockResolvedValue(fullyAuthorizedCheckPrivilegesResponse);
|
||||
|
||||
const result = await securityExtension.performAuthorization({ types, spaces, actions });
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'fully_authorized',
|
||||
typeMap: new Map()
|
||||
.set('a', {
|
||||
foo: { isGloballyAuthorized: true, authorizedSpaces: [] },
|
||||
bar: { isGloballyAuthorized: true, authorizedSpaces: [] },
|
||||
// Technically, 'login:' is not a saved object action, it is a Kibana privilege -- however, we include it in the `typeMap` results
|
||||
// for ease of use with the `redactNamespaces` function. The user is never actually authorized to "login" for a given object type,
|
||||
// they are authorized to log in on a per-space basis, and this is applied to each object type in the typeMap result accordingly.
|
||||
['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] },
|
||||
})
|
||||
.set('b', {
|
||||
foo: { authorizedSpaces: ['x', 'y'] },
|
||||
bar: { authorizedSpaces: ['x', 'y'] },
|
||||
['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] },
|
||||
})
|
||||
.set('c', {
|
||||
foo: { authorizedSpaces: ['x', 'y'] },
|
||||
bar: { authorizedSpaces: ['x', 'y'] },
|
||||
['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test('partially authorized', async () => {
|
||||
const { securityExtension, checkPrivileges } = setup();
|
||||
checkPrivileges.mockResolvedValue({
|
||||
hasAllRequested: false,
|
||||
privileges: {
|
||||
kibana: [
|
||||
// For type 'a', the user is authorized to use 'foo' action but not 'bar' action (all spaces)
|
||||
// For type 'b', the user is authorized to use 'foo' action but not 'bar' action (both spaces)
|
||||
// For type 'c', the user is authorized to use both actions in space 'x' but not space 'y'
|
||||
{ privilege: 'mock-saved_object:a/foo', authorized: true },
|
||||
{ privilege: 'mock-saved_object:a/bar', authorized: false },
|
||||
{ privilege: 'mock-saved_object:a/bar', authorized: true }, // fail-secure check
|
||||
{ resource: 'x', privilege: 'mock-saved_object:b/foo', authorized: true },
|
||||
{ resource: 'x', privilege: 'mock-saved_object:b/bar', authorized: false },
|
||||
{ resource: 'x', privilege: 'mock-saved_object:c/foo', authorized: true },
|
||||
{ privilege: 'mock-saved_object:c/foo', authorized: false }, // inverse fail-secure check
|
||||
{ resource: 'x', privilege: 'mock-saved_object:c/bar', authorized: true },
|
||||
{ resource: 'x', privilege: 'login:', authorized: true },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:b/foo', authorized: true },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:b/bar', authorized: false },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:c/foo', authorized: false },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:c/bar', authorized: false },
|
||||
{ privilege: 'mock-saved_object:c/bar', authorized: true }, // fail-secure check
|
||||
{ resource: 'y', privilege: 'mock-saved_object:c/bar', authorized: true }, // fail-secure check
|
||||
{ resource: 'y', privilege: 'login:', authorized: true },
|
||||
// The fail-secure checks are a contrived scenario, as we *shouldn't* get both an unauthorized and authorized result for a given resource...
|
||||
// However, in case we do, we should fail-secure (authorized + unauthorized = unauthorized)
|
||||
],
|
||||
},
|
||||
} as CheckPrivilegesResponse);
|
||||
|
||||
const result = await securityExtension.performAuthorization({ types, spaces, actions });
|
||||
expect(result).toEqual({
|
||||
status: 'partially_authorized',
|
||||
typeMap: new Map()
|
||||
.set('a', {
|
||||
foo: { isGloballyAuthorized: true, authorizedSpaces: [] },
|
||||
['login:']: { authorizedSpaces: ['x', 'y'] },
|
||||
})
|
||||
.set('b', {
|
||||
foo: { authorizedSpaces: ['x', 'y'] },
|
||||
['login:']: { authorizedSpaces: ['x', 'y'] },
|
||||
})
|
||||
.set('c', {
|
||||
foo: { authorizedSpaces: ['x'] },
|
||||
bar: { authorizedSpaces: ['x'] },
|
||||
['login:']: { authorizedSpaces: ['x', 'y'] },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test('unauthorized', async () => {
|
||||
const { securityExtension, checkPrivileges } = setup();
|
||||
checkPrivileges.mockResolvedValue({
|
||||
hasAllRequested: false,
|
||||
privileges: {
|
||||
kibana: [
|
||||
{ privilege: 'mock-saved_object:a/foo', authorized: false },
|
||||
{ privilege: 'mock-saved_object:a/bar', authorized: false },
|
||||
{ privilege: 'mock-saved_object:a/bar', authorized: true }, // fail-secure check
|
||||
{ resource: 'x', privilege: 'mock-saved_object:b/foo', authorized: false },
|
||||
{ resource: 'x', privilege: 'mock-saved_object:b/bar', authorized: false },
|
||||
{ resource: 'x', privilege: 'mock-saved_object:c/foo', authorized: false },
|
||||
{ resource: 'x', privilege: 'mock-saved_object:c/bar', authorized: false },
|
||||
{ resource: 'x', privilege: 'login:', authorized: false },
|
||||
{ resource: 'x', privilege: 'login:', authorized: true }, // fail-secure check
|
||||
{ resource: 'y', privilege: 'mock-saved_object:a/foo', authorized: false },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:a/bar', authorized: false },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:b/foo', authorized: false },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:b/bar', authorized: false },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:c/foo', authorized: false },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:c/bar', authorized: false },
|
||||
{ privilege: 'mock-saved_object:c/bar', authorized: true }, // fail-secure check
|
||||
{ resource: 'y', privilege: 'mock-saved_object:c/bar', authorized: true }, // fail-secure check
|
||||
{ resource: 'y', privilege: 'login:', authorized: true }, // should *not* result in a 'partially_authorized' status
|
||||
// The fail-secure checks are a contrived scenario, as we *shouldn't* get both an unauthorized and authorized result for a given resource...
|
||||
// However, in case we do, we should fail-secure (authorized + unauthorized = unauthorized)
|
||||
],
|
||||
},
|
||||
} as CheckPrivilegesResponse);
|
||||
|
||||
const result = await securityExtension.performAuthorization({ types, spaces, actions });
|
||||
expect(result).toEqual({
|
||||
// The user is authorized to log into space Y, but they are not authorized to take any actions on any of the requested object types.
|
||||
// Therefore, the status is 'unauthorized'.
|
||||
status: 'unauthorized',
|
||||
typeMap: new Map()
|
||||
.set('a', { ['login:']: { authorizedSpaces: ['y'] } })
|
||||
.set('b', { ['login:']: { authorizedSpaces: ['y'] } })
|
||||
.set('c', { ['login:']: { authorizedSpaces: ['y'] } }),
|
||||
});
|
||||
});
|
||||
|
||||
test('conflicting privilege failsafe', async () => {
|
||||
const conflictingPrivilegesResponse = {
|
||||
hasAllRequested: true,
|
||||
privileges: {
|
||||
kibana: [
|
||||
// redundant conflicting privileges for space X, type B, action Foo
|
||||
{ resource: 'x', privilege: 'mock-saved_object:b/foo', authorized: true },
|
||||
{ resource: 'x', privilege: 'mock-saved_object:b/foo', authorized: false },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:b/foo', authorized: true },
|
||||
],
|
||||
},
|
||||
} as CheckPrivilegesResponse;
|
||||
|
||||
const { securityExtension, checkPrivileges } = setup();
|
||||
checkPrivileges.mockResolvedValue(conflictingPrivilegesResponse);
|
||||
|
||||
const result = await securityExtension.performAuthorization({ types, spaces, actions });
|
||||
expect(result).toEqual({
|
||||
status: 'fully_authorized',
|
||||
typeMap: new Map().set('b', {
|
||||
foo: { authorizedSpaces: ['y'] }, // should NOT be authorized for conflicted privilege
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with enforce', () => {
|
||||
// These arguments are used for all unit tests below
|
||||
const types = new Set(['a', 'b', 'c']);
|
||||
const spaces = new Set(['x', 'y']);
|
||||
const actions = new Set(['foo']);
|
||||
|
||||
const fullyAuthorizedCheckPrivilegesResponse = {
|
||||
hasAllRequested: true,
|
||||
privileges: {
|
||||
kibana: [
|
||||
{ privilege: 'mock-saved_object:a/foo', authorized: true },
|
||||
{ privilege: 'login:', authorized: true },
|
||||
{ resource: 'x', privilege: 'mock-saved_object:b/foo', authorized: true },
|
||||
{ resource: 'x', privilege: 'mock-saved_object:c/foo', authorized: true },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:b/foo', authorized: true },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:c/foo', authorized: true },
|
||||
],
|
||||
},
|
||||
} as CheckPrivilegesResponse;
|
||||
|
||||
const partiallyAuthorizedCheckPrivilegesResponse = {
|
||||
hasAllRequested: false,
|
||||
privileges: {
|
||||
kibana: [
|
||||
{ privilege: 'mock-saved_object:a/foo', authorized: true },
|
||||
{ privilege: 'login:', authorized: true },
|
||||
{ resource: 'x', privilege: 'mock-saved_object:b/foo', authorized: true },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:c/foo', authorized: true },
|
||||
],
|
||||
},
|
||||
} as CheckPrivilegesResponse;
|
||||
|
||||
const unauthorizedCheckPrivilegesResponse = {
|
||||
hasAllRequested: false,
|
||||
privileges: {
|
||||
kibana: [
|
||||
{ privilege: 'login:', authorized: true },
|
||||
{ resource: 'x', privilege: 'mock-saved_object:a/foo', authorized: true },
|
||||
{ resource: 'y', privilege: 'mock-saved_object:b/foo', authorized: true },
|
||||
{ resource: 'z', privilege: 'mock-saved_object:c/foo', authorized: true },
|
||||
],
|
||||
},
|
||||
} as CheckPrivilegesResponse;
|
||||
|
||||
test('fully authorized', async () => {
|
||||
const { securityExtension, checkPrivileges } = setup();
|
||||
checkPrivileges.mockResolvedValue(fullyAuthorizedCheckPrivilegesResponse);
|
||||
|
||||
await expect(() =>
|
||||
securityExtension.performAuthorization({
|
||||
actions,
|
||||
types,
|
||||
spaces,
|
||||
enforceMap: new Map([
|
||||
['a', new Set(['x', 'y', 'z'])],
|
||||
['b', new Set(['x', 'y'])],
|
||||
['c', new Set(['y'])],
|
||||
]),
|
||||
})
|
||||
).not.toThrowError();
|
||||
});
|
||||
|
||||
test('partially authorized', async () => {
|
||||
const { securityExtension, checkPrivileges } = setup();
|
||||
checkPrivileges.mockResolvedValue(partiallyAuthorizedCheckPrivilegesResponse);
|
||||
|
||||
await expect(() =>
|
||||
securityExtension.performAuthorization({
|
||||
actions,
|
||||
types,
|
||||
spaces,
|
||||
enforceMap: new Map([
|
||||
['a', new Set(['x', 'y', 'z'])],
|
||||
['b', new Set(['x', 'y'])],
|
||||
['c', new Set(['x', 'y'])],
|
||||
]),
|
||||
})
|
||||
).rejects.toThrowError('Unable to foo b,c');
|
||||
});
|
||||
|
||||
test('unauthorized', async () => {
|
||||
const { securityExtension, checkPrivileges } = setup();
|
||||
checkPrivileges.mockResolvedValue(unauthorizedCheckPrivilegesResponse);
|
||||
|
||||
await expect(() =>
|
||||
securityExtension.performAuthorization({
|
||||
actions,
|
||||
types,
|
||||
spaces,
|
||||
enforceMap: new Map([
|
||||
['a', new Set(['y', 'z'])],
|
||||
['b', new Set(['x', 'z'])],
|
||||
['c', new Set(['x', 'y'])],
|
||||
]),
|
||||
})
|
||||
).rejects.toThrowError('Unable to foo a,b,c');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#addAuditEvent', () => {
|
||||
test(`adds an unknown audit event`, async () => {
|
||||
const { auditLogger, securityExtension } = setup();
|
||||
|
|
|
@ -15,6 +15,7 @@ import type {
|
|||
CheckAuthorizationResult,
|
||||
EnforceAuthorizationParams,
|
||||
ISavedObjectsSecurityExtension,
|
||||
PerformAuthorizationParams,
|
||||
RedactNamespacesParams,
|
||||
} from '@kbn/core-saved-objects-server';
|
||||
|
||||
|
@ -45,7 +46,7 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten
|
|||
this.checkPrivilegesFunc = checkPrivileges;
|
||||
}
|
||||
|
||||
async checkAuthorization<A extends string>(
|
||||
private async checkAuthorization<A extends string>(
|
||||
params: CheckAuthorizationParams<A>
|
||||
): Promise<CheckAuthorizationResult<A>> {
|
||||
const { types, spaces, actions, options = { allowGlobalResource: false } } = params;
|
||||
|
@ -158,6 +159,31 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten
|
|||
auditCallback?.();
|
||||
}
|
||||
|
||||
async performAuthorization<A extends string>(
|
||||
params: PerformAuthorizationParams<A>
|
||||
): Promise<CheckAuthorizationResult<A>> {
|
||||
const checkResult: CheckAuthorizationResult<A> = await this.checkAuthorization({
|
||||
types: params.types,
|
||||
spaces: params.spaces,
|
||||
actions: params.actions,
|
||||
options: { allowGlobalResource: params.options?.allowGlobalResource === true },
|
||||
});
|
||||
|
||||
const typesAndSpaces = params.enforceMap;
|
||||
if (typesAndSpaces !== undefined && checkResult) {
|
||||
params.actions.forEach((action) => {
|
||||
this.enforceAuthorization({
|
||||
typesAndSpaces,
|
||||
action,
|
||||
typeMap: checkResult.typeMap,
|
||||
auditCallback: params.auditCallback,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return checkResult;
|
||||
}
|
||||
|
||||
addAuditEvent(params: AddAuditEventParams): void {
|
||||
if (this.auditLogger.enabled) {
|
||||
const auditEvent = savedObjectEvent(params);
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import { savedObjectsExtensionsMock } from '@kbn/core-saved-objects-api-server-mocks';
|
||||
import type { ISavedObjectsSecurityExtension } from '@kbn/core-saved-objects-server';
|
||||
import { AuditAction } from '@kbn/core-saved-objects-server';
|
||||
import { setMapsAreEqual, setsAreEqual } from '@kbn/core-saved-objects-utils-server';
|
||||
import type { EcsEventOutcome, SavedObjectsFindResponse } from '@kbn/core/server';
|
||||
import { SavedObjectsErrorHelpers } from '@kbn/core/server';
|
||||
import { httpServerMock } from '@kbn/core/server/mocks';
|
||||
|
@ -732,15 +733,34 @@ describe('SecureSpacesClientWrapper', () => {
|
|||
|
||||
function expectAuthorizationCheck(
|
||||
securityExtension: jest.Mocked<ISavedObjectsSecurityExtension>,
|
||||
targetTypes: string[],
|
||||
targetSpaces: string[]
|
||||
aliases: Array<{ targetSpace: string; targetType: string }>
|
||||
) {
|
||||
expect(securityExtension!.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(securityExtension!.checkAuthorization).toHaveBeenCalledWith({
|
||||
types: new Set(targetTypes), // unique types of the alias targets
|
||||
spaces: new Set(targetSpaces), // unique spaces of the alias targets
|
||||
actions: new Set(['bulk_update']),
|
||||
expect(securityExtension.performAuthorization).toHaveBeenCalledTimes(1);
|
||||
|
||||
const targetTypes = aliases.map((alias) => alias.targetType);
|
||||
const targetSpaces = aliases.map((alias) => alias.targetSpace);
|
||||
|
||||
const expectedActions = new Set(['bulk_update']);
|
||||
const expectedSpaces = new Set(targetSpaces);
|
||||
const expectedTypes = new Set(targetTypes);
|
||||
const expectedEnforceMap = new Map<string, Set<string>>();
|
||||
aliases.forEach((alias) => {
|
||||
expectedEnforceMap.set(alias.targetType, new Set([alias.targetSpace]));
|
||||
});
|
||||
|
||||
const {
|
||||
actions: actualActions,
|
||||
spaces: actualSpaces,
|
||||
types: actualTypes,
|
||||
enforceMap: actualEnforceMap,
|
||||
options: actualOptions,
|
||||
} = securityExtension.performAuthorization.mock.calls[0][0];
|
||||
|
||||
expect(setsAreEqual(expectedActions, actualActions)).toBeTruthy();
|
||||
expect(setsAreEqual(expectedSpaces, actualSpaces)).toBeTruthy();
|
||||
expect(setsAreEqual(expectedTypes, actualTypes)).toBeTruthy();
|
||||
expect(setMapsAreEqual(expectedEnforceMap, actualEnforceMap)).toBeTruthy();
|
||||
expect(actualOptions).toBeUndefined();
|
||||
}
|
||||
|
||||
describe('when security is not enabled', () => {
|
||||
|
@ -764,12 +784,7 @@ describe('SecureSpacesClientWrapper', () => {
|
|||
const { wrapper, baseClient, forbiddenError, securityExtension } = setup({
|
||||
securityEnabled,
|
||||
});
|
||||
securityExtension!.checkAuthorization.mockResolvedValue({
|
||||
// These values don't actually matter, the call to enforceAuthorization matters
|
||||
status: 'unauthorized',
|
||||
typeMap: new Map(),
|
||||
});
|
||||
securityExtension!.enforceAuthorization.mockImplementation(() => {
|
||||
securityExtension!.performAuthorization.mockImplementation(() => {
|
||||
throw new Error('Oh no!');
|
||||
});
|
||||
const aliases = [alias1, alias2];
|
||||
|
@ -777,14 +792,14 @@ describe('SecureSpacesClientWrapper', () => {
|
|||
forbiddenError
|
||||
);
|
||||
|
||||
expectAuthorizationCheck(securityExtension!, ['type-1', 'type-2'], ['space-1', 'space-2']);
|
||||
expectAuthorizationCheck(securityExtension!, aliases);
|
||||
expectAuditEvents(securityExtension!, aliases, { error: true });
|
||||
expect(baseClient.disableLegacyUrlAliases).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates the legacy URL aliases when authorized', async () => {
|
||||
const { wrapper, baseClient, securityExtension } = setup({ securityEnabled });
|
||||
securityExtension!.checkAuthorization.mockResolvedValue({
|
||||
securityExtension!.performAuthorization.mockResolvedValue({
|
||||
// These values don't actually matter, the call to enforceAuthorization matters
|
||||
status: 'fully_authorized',
|
||||
typeMap: new Map(),
|
||||
|
@ -793,7 +808,7 @@ describe('SecureSpacesClientWrapper', () => {
|
|||
const aliases = [alias1, alias2];
|
||||
await wrapper.disableLegacyUrlAliases(aliases);
|
||||
|
||||
expectAuthorizationCheck(securityExtension!, ['type-1', 'type-2'], ['space-1', 'space-2']);
|
||||
expectAuthorizationCheck(securityExtension!, aliases);
|
||||
expectAuditEvents(securityExtension!, aliases, { error: false });
|
||||
expect(baseClient.disableLegacyUrlAliases).toHaveBeenCalledTimes(1);
|
||||
expect(baseClient.disableLegacyUrlAliases).toHaveBeenCalledWith(aliases);
|
||||
|
|
|
@ -322,23 +322,20 @@ export class SecureSpacesClientWrapper implements ISpacesClient {
|
|||
[new Set<string>(), new Map<string, Set<string>>()]
|
||||
);
|
||||
|
||||
const { typeMap } = await this.securityExtension.checkAuthorization({
|
||||
types: new Set(typesAndSpaces.keys()),
|
||||
spaces: uniqueSpaces,
|
||||
actions: new Set(['bulk_update']),
|
||||
});
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
await this.securityExtension.enforceAuthorization({
|
||||
typesAndSpaces,
|
||||
action: 'bulk_update',
|
||||
typeMap,
|
||||
await this.securityExtension.performAuthorization({
|
||||
actions: new Set(['bulk_update']),
|
||||
types: new Set(typesAndSpaces.keys()),
|
||||
spaces: uniqueSpaces,
|
||||
enforceMap: typesAndSpaces,
|
||||
});
|
||||
} catch (err) {
|
||||
error = this.errors.decorateForbiddenError(
|
||||
new Error(`Unable to disable aliases: ${err.message}`)
|
||||
);
|
||||
}
|
||||
|
||||
for (const alias of aliases) {
|
||||
const id = getAliasId(alias);
|
||||
this.securityExtension.addAuditEvent({
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue