[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:
Jeramy Soucy 2022-12-19 15:00:29 -05:00 committed by GitHub
parent 800f45180c
commit 88733fc48f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 1335 additions and 1248 deletions

View file

@ -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({

View file

@ -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,

View file

@ -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);

View file

@ -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({

View file

@ -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);

View file

@ -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

View file

@ -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 }));
});
});
});

View file

@ -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;

View file

@ -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(),

View file

@ -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[],

View file

@ -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(),

View file

@ -72,6 +72,7 @@ export type {
} from './src/extensions/encryption';
export type {
CheckAuthorizationParams,
PerformAuthorizationParams,
AuthorizationTypeEntry,
AuthorizationTypeMap,
CheckAuthorizationResult,

View file

@ -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>>;
/**

View file

@ -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';

View file

@ -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();
});
});
});
});

View file

@ -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;
}

View file

@ -356,7 +356,7 @@ export type {
SavedObjectsRequestHandlerContext,
EncryptedObjectDescriptor,
ISavedObjectsEncryptionExtension,
CheckAuthorizationParams,
PerformAuthorizationParams,
AuthorizationTypeEntry,
AuthorizationTypeMap,
CheckAuthorizationResult,

View file

@ -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();

View file

@ -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);

View file

@ -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);

View file

@ -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({