diff --git a/.buildkite/ftr_platform_stateful_configs.yml b/.buildkite/ftr_platform_stateful_configs.yml index ce73dcbbd6c92..8c32728070fcb 100644 --- a/.buildkite/ftr_platform_stateful_configs.yml +++ b/.buildkite/ftr_platform_stateful_configs.yml @@ -338,6 +338,8 @@ enabled: - x-pack/platform/test/spaces_api_integration/security_and_spaces/config_basic.ts - x-pack/platform/test/spaces_api_integration/security_and_spaces/config_trial.ts - x-pack/platform/test/spaces_api_integration/spaces_only/config.ts + - x-pack/platform/test/spaces_api_integration/access_control_objects/config.ts + - x-pack/platform/test/spaces_api_integration/access_control_objects/feature_disabled.config.ts - x-pack/platform/test/task_manager_claimer_update_by_query/config.ts - x-pack/platform/test/ui_capabilities/security_and_spaces/config.ts - x-pack/platform/test/ui_capabilities/spaces_only/config.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3b9aebbb1a783..439e92c1f948a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -404,6 +404,8 @@ src/platform/packages/private/shared-ux/storybook/config @elastic/appex-sharedux src/platform/packages/shared/chart-expressions-common @elastic/kibana-visualizations src/platform/packages/shared/chart-test-jest-helpers @elastic/kibana-visualizations src/platform/packages/shared/cloud @elastic/kibana-core +src/platform/packages/shared/content-management/access_control/access_control_public @elastic/appex-sharedux +src/platform/packages/shared/content-management/access_control/access_control_server @elastic/appex-sharedux src/platform/packages/shared/content-management/content_editor @elastic/appex-sharedux src/platform/packages/shared/content-management/content_insights/content_insights_public @elastic/appex-sharedux src/platform/packages/shared/content-management/content_insights/content_insights_server @elastic/appex-sharedux @@ -1126,6 +1128,7 @@ x-pack/platform/test/security_api_integration/plugins/oidc_provider @elastic/kib x-pack/platform/test/security_api_integration/plugins/saml_provider @elastic/kibana-security x-pack/platform/test/security_api_integration/plugins/user_profiles_consumer @elastic/kibana-security x-pack/platform/test/security_functional/plugins/test_endpoints @elastic/kibana-security +x-pack/platform/test/spaces_api_integration/common/plugins/access_control_test_plugin @elastic/kibana-security x-pack/platform/test/spaces_api_integration/common/plugins/spaces_test_plugin @elastic/kibana-security x-pack/platform/test/task_manager_claimer_update_by_query/plugins/sample_task_plugin_mget @elastic/response-ops x-pack/platform/test/ui_capabilities/common/plugins/foo_plugin @elastic/kibana-security diff --git a/package.json b/package.json index d795459a9a88f..368f2df445a38 100644 --- a/package.json +++ b/package.json @@ -166,6 +166,7 @@ "@hapi/wreck": "^18.1.0", "@hello-pangea/dnd": "18.0.1", "@kbn/aad-fixtures-plugin": "link:x-pack/platform/test/alerting_api_integration/common/plugins/aad", + "@kbn/access-control-test-plugin": "link:x-pack/platform/test/spaces_api_integration/common/plugins/access_control_test_plugin", "@kbn/actions-plugin": "link:x-pack/platform/plugins/shared/actions", "@kbn/actions-simulators-plugin": "link:x-pack/platform/test/alerting_api_integration/common/plugins/actions_simulators", "@kbn/actions-types": "link:src/platform/packages/shared/kbn-actions-types", @@ -261,6 +262,8 @@ "@kbn/connector-specs": "link:src/platform/packages/shared/kbn-connector-specs", "@kbn/console-plugin": "link:src/platform/plugins/shared/console", "@kbn/content-connectors-plugin": "link:x-pack/platform/plugins/shared/content_connectors", + "@kbn/content-management-access-control-public": "link:src/platform/packages/shared/content-management/access_control/access_control_public", + "@kbn/content-management-access-control-server": "link:src/platform/packages/shared/content-management/access_control/access_control_server", "@kbn/content-management-content-editor": "link:src/platform/packages/shared/content-management/content_editor", "@kbn/content-management-content-insights-public": "link:src/platform/packages/shared/content-management/content_insights/content_insights_public", "@kbn/content-management-content-insights-server": "link:src/platform/packages/shared/content-management/content_insights/content_insights_server", diff --git a/src/core/packages/plugins/server-internal/src/plugin_context.ts b/src/core/packages/plugins/server-internal/src/plugin_context.ts index 0dcb19269ef52..0d46f509f3782 100644 --- a/src/core/packages/plugins/server-internal/src/plugin_context.ts +++ b/src/core/packages/plugins/server-internal/src/plugin_context.ts @@ -266,8 +266,10 @@ export function createPluginSetupContext({ setEncryptionExtension: deps.savedObjects.setEncryptionExtension, setSecurityExtension: deps.savedObjects.setSecurityExtension, setSpacesExtension: deps.savedObjects.setSpacesExtension, + setAccessControlTransforms: deps.savedObjects.setAccessControlTransforms, registerType: deps.savedObjects.registerType, getDefaultIndex: deps.savedObjects.getDefaultIndex, + isAccessControlEnabled: deps.savedObjects.isAccessControlEnabled, }, status: { core$: deps.status.core$, diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/bulk_create.test.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/bulk_create.test.ts index 9bc981a765f82..8d38cee0b81bb 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/bulk_create.test.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/bulk_create.test.ts @@ -19,7 +19,10 @@ import { import type { Payload } from '@hapi/boom'; -import type { SavedObjectsBulkCreateObject } from '@kbn/core-saved-objects-api-server'; +import type { + SavedObjectAccessControl, + SavedObjectsBulkCreateObject, +} from '@kbn/core-saved-objects-api-server'; import { type SavedObjectsRawDoc, type SavedObjectUnsanitizedDoc, @@ -58,6 +61,7 @@ import { } from '../../test_helpers/repository.test.common'; import type { ISavedObjectsSecurityExtension } from '@kbn/core-saved-objects-server'; import { savedObjectsExtensionsMock } from '../../mocks/saved_objects_extensions.mock'; +import type { AuthenticatedUser } from '@kbn/core-security-common'; // so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient. @@ -1086,6 +1090,266 @@ describe('#bulkCreate', () => { }) ); }); + + describe('access control', () => { + const CURRENT_USER_PROFILE_ID = 'current_user_profile_id'; + const ACCESS_CONTROL_TYPE = 'access-control-type'; + + beforeEach(() => { + securityExtension.getCurrentUser.mockReturnValue({ + authentication_realm: { + name: 'authentication_realm', + type: 'authentication_realm_type', + }, + lookup_realm: { + name: 'lookup_realm', + type: 'lookup_realm_type', + }, + authentication_provider: { + name: 'authentication_provider', + type: 'authentication_provider_type', + }, + authentication_type: 'realm', + elastic_cloud_user: false, + profile_uid: CURRENT_USER_PROFILE_ID, + } as AuthenticatedUser); + }); + + registry.registerType({ + name: ACCESS_CONTROL_TYPE, + hidden: false, + namespaceType: 'multiple-isolated', + supportsAccessControl: true, + mappings: { + dynamic: false, + properties: { + description: { type: 'text' }, + }, + }, + management: { + importableAndExportable: true, + }, + }); + + afterEach(() => { + securityExtension.getCurrentUser.mockClear(); + }); + + it('applies access control options only to applicable types when using global access control options', async () => { + const obj1NoAccessControl = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + references: [{ name: 'ref_0', type: 'test', id: '1' }], + }; + const obj2AccessControl = { + type: ACCESS_CONTROL_TYPE, + id: 'has-read-only-metadata', + attributes: { title: 'Test Two' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + }; + await bulkCreateSuccess(client, repository, [obj1NoAccessControl, obj2AccessControl], { + accessControl: { accessMode: 'write_restricted' }, + }); + + expect(securityExtension.authorizeBulkCreate).toHaveBeenCalledWith( + expect.objectContaining({ + objects: expect.arrayContaining([ + { + type: ACCESS_CONTROL_TYPE, + id: 'has-read-only-metadata', + name: 'Test Two', + existingNamespaces: [], + initialNamespace: undefined, + accessControl: { + owner: CURRENT_USER_PROFILE_ID, + accessMode: 'write_restricted', + }, + }, + ]), + }) + ); + }); + + it('applies access control options to supporting types when using per object access control options', async () => { + const obj1NoAccessControl = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + references: [{ name: 'ref_0', type: 'test', id: '1' }], + }; + const obj2AccessControl = { + type: ACCESS_CONTROL_TYPE, + id: 'has-read-only-metadata', + attributes: { title: 'Test Two' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + accessControl: { + accessMode: 'write_restricted', + } as Pick, + }; + await bulkCreateSuccess(client, repository, [obj1NoAccessControl, obj2AccessControl]); + + expect(securityExtension.authorizeBulkCreate).toHaveBeenCalledWith({ + objects: [ + { + type: 'config', + id: '6.0.0-alpha1', + name: 'Test One', + existingNamespaces: [], + initialNamespaces: undefined, + }, + { + type: ACCESS_CONTROL_TYPE, + id: 'has-read-only-metadata', + name: 'Test Two', + existingNamespaces: [], + initialNamespaces: undefined, + accessControl: { + owner: CURRENT_USER_PROFILE_ID, + accessMode: 'write_restricted', + }, + }, + ], + }); + }); + + it('overrides access control options with incoming object property only for applicable types', async () => { + const obj1NoAccessControl = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + references: [{ name: 'ref_0', type: 'test', id: '1' }], + accessControl: { + accessMode: 'write_restricted', + } as Pick, + }; + const obj2AccessControl = { + type: ACCESS_CONTROL_TYPE, + id: 'has-read-only-metadata', + attributes: { title: 'Test Two' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + accessControl: { + accessMode: 'default', // default === RBAC-only + } as Pick, + }; + const obj3AccessControl = { + type: ACCESS_CONTROL_TYPE, + id: 'has-read-only-metadata', + attributes: { title: 'Test Three' }, + references: [{ name: 'ref_0', type: 'test', id: '3' }], + }; + const obj4NoAccessControl = { + type: 'config', + id: '6.0.0-alpha4', + attributes: { title: 'Test One' }, + references: [{ name: 'ref_0', type: 'test', id: '1' }], + }; + + await bulkCreateSuccess( + client, + repository, + [obj1NoAccessControl, obj2AccessControl, obj3AccessControl, obj4NoAccessControl], + { + accessControl: { accessMode: 'write_restricted' }, + } + ); + + expect(securityExtension.authorizeBulkCreate).toHaveBeenCalledWith({ + objects: [ + { + type: ACCESS_CONTROL_TYPE, + id: 'has-read-only-metadata', + name: 'Test Two', + existingNamespaces: [], + initialNamespace: undefined, + accessControl: { + owner: CURRENT_USER_PROFILE_ID, + accessMode: 'default', // explicitly confirm the mode is overriden + }, + }, + { + type: ACCESS_CONTROL_TYPE, + id: 'has-read-only-metadata', + name: 'Test Three', + existingNamespaces: [], + initialNamespace: undefined, + accessControl: { + owner: CURRENT_USER_PROFILE_ID, + accessMode: 'write_restricted', // explicitly confirm the mode is NOT overriden + }, + }, + ], + }); + }); + + it('does not create objects with access control when there is no active user profile', async () => { + securityExtension.getCurrentUser.mockReturnValueOnce(null); + + const obj1NoAccessControl = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + references: [{ name: 'ref_0', type: 'test', id: '1' }], + accessControl: { + accessMode: 'write_restricted', + } as Pick, + }; + const obj2AccessControl = { + type: ACCESS_CONTROL_TYPE, + id: 'has-read-only-metadata', + attributes: { title: 'Test Two' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + accessControl: { + accessMode: 'write_restricted', + } as Pick, + }; + await bulkCreateSuccess(client, repository, [obj1NoAccessControl, obj2AccessControl]); + + expect(securityExtension.authorizeBulkCreate).not.toHaveBeenCalled(); + }); + + // regression test + it('creates objects supporting access control with no access control metadata when there is no active user profile and no access mode is provided', async () => { + securityExtension.getCurrentUser.mockReturnValueOnce(null); + + const obj1NoAccessControl = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + references: [{ name: 'ref_0', type: 'test', id: '1' }], + }; + const obj2AccessControl = { + type: ACCESS_CONTROL_TYPE, + id: 'could-have-read-only-metadata', + attributes: { title: 'Test Two' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + }; + await bulkCreateSuccess(client, repository, [obj1NoAccessControl, obj2AccessControl]); // no accessControl options + + expect(securityExtension.authorizeBulkCreate).toHaveBeenCalledWith( + expect.objectContaining({ + objects: [ + { + type: 'config', + id: '6.0.0-alpha1', + name: 'Test One', + existingNamespaces: [], + initialNamespace: undefined, + // explicitly confirm there is no accessControl for non-supporting type + }, + { + type: ACCESS_CONTROL_TYPE, + id: 'could-have-read-only-metadata', + name: 'Test Two', + existingNamespaces: [], + initialNamespace: undefined, + // explicitly confirm there is no accessControl for supporting type + }, + ], + }) + ); + }); + }); }); }); }); diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/bulk_create.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/bulk_create.ts index 8a48f9e1ccd0c..56808418aa15b 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/bulk_create.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/bulk_create.ts @@ -8,39 +8,37 @@ */ import type { Payload } from '@hapi/boom'; -import type { - DecoratedError, - AuthorizeCreateObject, - SavedObjectsRawDoc, -} from '@kbn/core-saved-objects-server'; +import type { AuthorizeCreateObject, SavedObjectsRawDoc } from '@kbn/core-saved-objects-server'; import { SavedObjectsErrorHelpers, + errorContent, type SavedObject, type SavedObjectSanitizedDoc, } from '@kbn/core-saved-objects-server'; import { SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server'; -import type { - SavedObjectsCreateOptions, - SavedObjectsBulkCreateObject, - SavedObjectsBulkResponse, +import { + type SavedObjectsCreateOptions, + type SavedObjectsBulkCreateObject, + type SavedObjectsBulkResponse, + type SavedObjectAccessControl, + type Either, + left, + right, + isRight, + isLeft, } from '@kbn/core-saved-objects-api-server'; import { DEFAULT_REFRESH_SETTING } from '../constants'; -import type { Either } from './utils'; import { getBulkOperationError, getCurrentTime, getExpectedVersionProperties, - left, - right, - isLeft, - isRight, normalizeNamespace, setManaged, - errorContent, } from './utils'; import { getSavedObjectNamespaces } from './utils'; import type { PreflightCheckForCreateObject } from './internals/preflight_check_for_create'; import type { ApiExecutionContext } from './types'; +import { setAccessControl } from './utils/internal_utils'; export interface PerformBulkCreateParams { objects: Array>; @@ -51,8 +49,13 @@ type ExpectedResult = Either< { type: string; id?: string; error: Payload }, { method: 'index' | 'create'; - object: SavedObjectsBulkCreateObject & { id: string }; - preflightCheckIndex?: number; + object: Omit & { + id: string; + accessControl?: SavedObjectAccessControl; + }; + id: string; + type: string; + esRequestIndex?: number; } >; @@ -91,29 +94,68 @@ export const performBulkCreate = async ( const updatedBy = createdBy; let preflightCheckIndexCounter = 0; + const expectedResults = objects.map((object) => { const { type, id: requestId, initialNamespaces, version, managed } = object; - let error: DecoratedError | undefined; + let id: string = ''; // Assign to make TS happy, the ID will be validated (or randomly generated if needed) during getValidId below const objectManaged = managed; if (!allowedTypes.includes(type)) { - error = SavedObjectsErrorHelpers.createUnsupportedTypeError(type); + const error = SavedObjectsErrorHelpers.createUnsupportedTypeError(type); + return left({ id: requestId, type, error: errorContent(error) }); } else { try { id = commonHelper.getValidId(type, requestId, version, overwrite); validationHelper.validateInitialNamespaces(type, initialNamespaces); validationHelper.validateOriginId(type, object); } catch (e) { - error = e; + return left({ id: requestId, type, error: errorContent(e) }); } } - - if (error) { - return left({ id: requestId, type, error: errorContent(error) }); - } - const method = requestId && overwrite ? 'index' : 'create'; const requiresNamespacesCheck = requestId && registry.isMultiNamespace(type); + const typeSupportsAccessControl = registry.supportsAccessControl(type); + const paramsIncludeAccessControl = + !!object.accessControl?.accessMode || !!options.accessControl?.accessMode; + let accessControlToWrite: SavedObjectAccessControl | undefined; + if (securityExtension) { + if (!typeSupportsAccessControl && paramsIncludeAccessControl) { + return left({ + id, + type, + error: { + ...errorContent( + SavedObjectsErrorHelpers.createBadRequestError( + `Cannot create a saved object of type ${type} with an access mode because the type does not support access control` + ) + ), + }, + }); + } + + if (!createdBy && typeSupportsAccessControl && paramsIncludeAccessControl) { + return left({ + id, + type, + error: { + ...errorContent( + SavedObjectsErrorHelpers.createBadRequestError( + `Cannot create a saved object of type ${type} with an access mode because Kibana could not determine the user profile ID for the caller. Access control requires an identifiable user profile` + ) + ), + }, + }); + } + + const accessMode = + object.accessControl?.accessMode ?? options.accessControl?.accessMode ?? 'default'; + + accessControlToWrite = setAccessControl({ + typeSupportsAccessControl, + createdBy, + accessMode, + }); + } return right({ method, @@ -121,8 +163,11 @@ export const performBulkCreate = async ( ...object, id, managed: setManaged({ optionsManaged, objectManaged }), + accessControl: accessControlToWrite, }, - ...(requiresNamespacesCheck && { preflightCheckIndex: preflightCheckIndexCounter++ }), + id, + type: object.type, + ...(requiresNamespacesCheck && { esRequestIndex: preflightCheckIndexCounter++ }), }) as ExpectedResult; }); @@ -139,7 +184,7 @@ export const performBulkCreate = async ( const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace); const preflightCheckObjects = validObjects - .filter(({ value }) => value.preflightCheckIndex !== undefined) + .filter(({ value }) => value.esRequestIndex !== undefined) .map(({ value }) => { const { type, id, initialNamespaces } = value.object; const namespaces = initialNamespaces ?? [namespaceString]; @@ -150,8 +195,16 @@ export const performBulkCreate = async ( ); const authObjects: AuthorizeCreateObject[] = validObjects.map((element) => { - const { object, preflightCheckIndex: index } = element.value; - const preflightResult = index !== undefined ? preflightCheckResponse[index] : undefined; + const { object, esRequestIndex } = element.value; + + const preflightResult = + esRequestIndex !== undefined ? preflightCheckResponse[esRequestIndex] : undefined; + + const existingAccessControl = preflightResult?.existingDocument?._source?.accessControl; + + const accessControlToWrite = existingAccessControl + ? existingAccessControl + : object.accessControl; return { type: object.type, @@ -159,6 +212,7 @@ export const performBulkCreate = async ( initialNamespaces: object.initialNamespaces, existingNamespaces: preflightResult?.existingDocument?._source.namespaces ?? [], name: SavedObjectsUtils.getName(registry.getNameAttribute(object.type), object), + ...(accessControlToWrite && { accessControl: accessControlToWrite }), }; }); @@ -167,6 +221,16 @@ export const performBulkCreate = async ( objects: authObjects, }); + const inaccessibleObjects = authorizationResult?.inaccessibleObjects + ? Array.from(authorizationResult.inaccessibleObjects) + : []; + + const expectedAuthorizedResults = await securityExtension?.filterInaccessibleObjectsForBulkAction( + expectedResults, + inaccessibleObjects, + 'bulk_create' + ); + let bulkRequestIndexCounter = 0; const bulkCreateParams: object[] = []; type ExpectedBulkResult = Either< @@ -174,112 +238,117 @@ export const performBulkCreate = async ( { esRequestIndex: number; requestedId: string; rawMigratedDoc: SavedObjectsRawDoc } >; const expectedBulkResults = await Promise.all( - expectedResults.map>(async (expectedBulkGetResult) => { - if (isLeft(expectedBulkGetResult)) { - return expectedBulkGetResult; - } - - let savedObjectNamespace: string | undefined; - let savedObjectNamespaces: string[] | undefined; - let existingOriginId: string | undefined; - let versionProperties; - const { - preflightCheckIndex, - object: { initialNamespaces, version, ...object }, - method, - } = expectedBulkGetResult.value; - if (preflightCheckIndex !== undefined) { - const preflightResult = preflightCheckResponse[preflightCheckIndex]; - const { type, id, existingDocument, error } = preflightResult; - if (error) { - const { metadata } = error; - return left({ - id, - type, - error: { - ...errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)), - ...(metadata && { metadata }), - }, - }); - } - savedObjectNamespaces = - initialNamespaces || getSavedObjectNamespaces(namespace, existingDocument); - versionProperties = getExpectedVersionProperties(version); - existingOriginId = existingDocument?._source?.originId; - } else { - if (registry.isSingleNamespace(object.type)) { - savedObjectNamespace = initialNamespaces - ? normalizeNamespace(initialNamespaces[0]) - : namespace; - } else if (registry.isMultiNamespace(object.type)) { - savedObjectNamespaces = initialNamespaces || getSavedObjectNamespaces(namespace); + (expectedAuthorizedResults ?? expectedResults).map>( + async (expectedBulkGetResult) => { + if (isLeft(expectedBulkGetResult)) { + return expectedBulkGetResult; } - versionProperties = getExpectedVersionProperties(version); - } - // 1. If the originId has been *explicitly set* in the options (defined or undefined), respect that. - // 2. Otherwise, preserve the originId of the existing object that is being overwritten, if any. - const originId = Object.keys(object).includes('originId') - ? object.originId - : existingOriginId; - const migrated = migrationHelper.migrateInputDocument({ - id: object.id, - type: object.type, - attributes: await encryptionHelper.optionallyEncryptAttributes( - object.type, - object.id, - savedObjectNamespace, // only used for multi-namespace object types - object.attributes - ), - migrationVersion: object.migrationVersion, - coreMigrationVersion: object.coreMigrationVersion, - typeMigrationVersion: object.typeMigrationVersion, - ...(savedObjectNamespace && { namespace: savedObjectNamespace }), - ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), - managed: setManaged({ optionsManaged, objectManaged: object.managed }), - updated_at: time, - created_at: time, - ...(createdBy && { created_by: createdBy }), - ...(updatedBy && { updated_by: updatedBy }), - references: object.references || [], - originId, - }) as SavedObjectSanitizedDoc; - - /** - * If a validation has been registered for this type, we run it against the migrated attributes. - * This is an imperfect solution because malformed attributes could have already caused the - * migration to fail, but it's the best we can do without devising a way to run validations - * inside the migration algorithm itself. - */ - try { - validationHelper.validateObjectForCreate(object.type, migrated); - } catch (error) { - return left({ + let savedObjectNamespace: string | undefined; + let savedObjectNamespaces: string[] | undefined; + let existingOriginId: string | undefined; + let versionProperties; + let accessControl: SavedObjectAccessControl | undefined; + const { + esRequestIndex, + object: { initialNamespaces, version, ...object }, + method, + } = expectedBulkGetResult.value; + if (esRequestIndex !== undefined) { + const preflightResult = preflightCheckResponse[esRequestIndex]; + const { type, id, existingDocument, error } = preflightResult; + if (error) { + const { metadata } = error; + return left({ + id, + type, + error: { + ...errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)), + ...(metadata && { metadata }), + }, + }); + } + savedObjectNamespaces = + initialNamespaces || getSavedObjectNamespaces(namespace, existingDocument); + versionProperties = getExpectedVersionProperties(version); + existingOriginId = existingDocument?._source?.originId; + accessControl = existingDocument?._source?.accessControl; + } else { + if (registry.isSingleNamespace(object.type)) { + savedObjectNamespace = initialNamespaces + ? normalizeNamespace(initialNamespaces[0]) + : namespace; + } else if (registry.isMultiNamespace(object.type)) { + savedObjectNamespaces = initialNamespaces || getSavedObjectNamespaces(namespace); + } + versionProperties = getExpectedVersionProperties(version); + } + const accessControlToWrite = accessControl ? accessControl : object.accessControl; + // 1. If the originId has been *explicitly set* in the options (defined or undefined), respect that. + // 2. Otherwise, preserve the originId of the existing object that is being overwritten, if any. + const originId = Object.keys(object).includes('originId') + ? object.originId + : existingOriginId; + const migrated = migrationHelper.migrateInputDocument({ id: object.id, type: object.type, - error, - }); - } + attributes: await encryptionHelper.optionallyEncryptAttributes( + object.type, + object.id, + savedObjectNamespace, // only used for multi-namespace object types + object.attributes + ), + migrationVersion: object.migrationVersion, + coreMigrationVersion: object.coreMigrationVersion, + typeMigrationVersion: object.typeMigrationVersion, + ...(savedObjectNamespace && { namespace: savedObjectNamespace }), + ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), + managed: setManaged({ optionsManaged, objectManaged: object.managed }), + ...(accessControlToWrite && { accessControl: accessControlToWrite }), + updated_at: time, + created_at: time, + ...(createdBy && { created_by: createdBy }), + ...(updatedBy && { updated_by: updatedBy }), + references: object.references || [], + originId, + }) as SavedObjectSanitizedDoc; - const expectedResult = { - esRequestIndex: bulkRequestIndexCounter++, - requestedId: object.id, - rawMigratedDoc: serializer.savedObjectToRaw(migrated), - }; + /** + * If a validation has been registered for this type, we run it against the migrated attributes. + * This is an imperfect solution because malformed attributes could have already caused the + * migration to fail, but it's the best we can do without devising a way to run validations + * inside the migration algorithm itself. + */ + try { + validationHelper.validateObjectForCreate(object.type, migrated); + } catch (error) { + return left({ + id: object.id, + type: object.type, + error, + }); + } - bulkCreateParams.push( - { - [method]: { - _id: expectedResult.rawMigratedDoc._id, - _index: commonHelper.getIndexForType(object.type), - ...(overwrite && versionProperties), + const expectedResult = { + esRequestIndex: bulkRequestIndexCounter++, + requestedId: object.id, + rawMigratedDoc: serializer.savedObjectToRaw(migrated), + }; + + bulkCreateParams.push( + { + [method]: { + _id: expectedResult.rawMigratedDoc._id, + _index: commonHelper.getIndexForType(object.type), + ...(overwrite && versionProperties), + }, }, - }, - expectedResult.rawMigratedDoc._source - ); + expectedResult.rawMigratedDoc._source + ); - return right(expectedResult); - }) + return right(expectedResult); + } + ) ); const bulkResponse = bulkCreateParams.length diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/bulk_delete.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/bulk_delete.ts index b1026729b94d5..83549091928cc 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/bulk_delete.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/bulk_delete.ts @@ -14,24 +14,23 @@ import type { SavedObjectsRawDoc, ISavedObjectsSecurityExtension, } from '@kbn/core-saved-objects-server'; -import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; +import { SavedObjectsErrorHelpers, errorContent } from '@kbn/core-saved-objects-server'; import { ALL_NAMESPACES_STRING, SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server'; -import type { - SavedObjectsBulkDeleteObject, - SavedObjectsBulkDeleteOptions, - SavedObjectsBulkDeleteResponse, +import { + isLeft, + isRight, + left, + right, + type SavedObjectsBulkDeleteObject, + type SavedObjectsBulkDeleteOptions, + type SavedObjectsBulkDeleteResponse, } from '@kbn/core-saved-objects-api-server'; import { DEFAULT_REFRESH_SETTING, MAX_CONCURRENT_ALIAS_DELETIONS } from '../constants'; import { - errorContent, getBulkOperationError, getExpectedVersionProperties, - isLeft, isMgetDoc, rawDocExistsInNamespace, - isRight, - left, - right, } from './utils'; import type { ApiExecutionContext } from './types'; import { deleteLegacyUrlAliases } from './internals/delete_legacy_url_aliases'; @@ -85,56 +84,70 @@ export const performBulkDelete = async ( }); // First round of filtering (Left: object doesn't exist/doesn't exist in namespace, Right: good to proceed) - const expectedBulkDeleteMultiNamespaceDocsResults = - getExpectedBulkDeleteMultiNamespaceDocsResults( - { - expectedBulkGetResults, - multiNamespaceDocsResponse, - namespace, - force, - }, - registry - ); + const expectedMultiNamespaceResults = getExpectedBulkDeleteMultiNamespaceDocsResults( + { + expectedBulkGetResults, + multiNamespaceDocsResponse, + namespace, + force, + }, + registry + ); + + let expectedResults: ExpectedBulkDeleteResult[]; if (securityExtension) { // Perform Auth Check (on both L/R, we'll deal with that later) - const authObjects: AuthorizeUpdateObject[] = expectedBulkDeleteMultiNamespaceDocsResults.map( - (element) => { - const index = (element.value as { esRequestIndex: number }).esRequestIndex; - const { type, id } = element.value; - const preflightResult = - index !== undefined ? multiNamespaceDocsResponse?.body.docs[index] : undefined; - - const name = preflightResult - ? SavedObjectsUtils.getName( - registry.getNameAttribute(type), - // @ts-expect-error MultiGetHit._source is optional - { attributes: preflightResult?._source?.[type] } - ) - : undefined; + const authObjects: AuthorizeUpdateObject[] = expectedMultiNamespaceResults.map((element) => { + const index = (element.value as { esRequestIndex: number }).esRequestIndex; + const { type, id } = element.value; + const preflightResult = + index !== undefined ? multiNamespaceDocsResponse?.body.docs[index] : undefined; - return { - type, - id, - name, - // @ts-expect-error MultiGetHit._source is optional - existingNamespaces: preflightResult?._source?.namespaces ?? [], - }; - } - ); + // @ts-expect-error MultiGetHit._source is optional + const accessControl = preflightResult?._source?.accessControl; + const name = preflightResult + ? SavedObjectsUtils.getName( + registry.getNameAttribute(type), + // @ts-expect-error MultiGetHit._source is optional + { attributes: preflightResult?._source?.[type] } + ) + : undefined; - await securityExtension.authorizeBulkDelete({ namespace, objects: authObjects }); - } + return { + type, + id, + name, + ...(accessControl ? { accessControl } : {}), + // @ts-expect-error MultiGetHit._source is optional + existingNamespaces: preflightResult?._source?.namespaces ?? [], + }; + }); + + const authorizationResult = await securityExtension.authorizeBulkDelete({ + namespace, + objects: authObjects, + }); + + const inaccessibleObjects = authorizationResult?.inaccessibleObjects + ? Array.from(authorizationResult.inaccessibleObjects) + : []; + + expectedResults = await securityExtension.filterInaccessibleObjectsForBulkAction( + expectedMultiNamespaceResults, + inaccessibleObjects, + 'bulk_delete', + true // reindex of esRequestIndex field needed to map subsequent bulk delete results below + ); + } else expectedResults = expectedMultiNamespaceResults; // Filter valid objects - const validObjects = expectedBulkDeleteMultiNamespaceDocsResults.filter(isRight); + const validObjects = expectedResults.filter(isRight); if (validObjects.length === 0) { - // We only have error results; return early to avoid potentially trying authZ checks for 0 types which would result in an exception. - const savedObjects = expectedBulkDeleteMultiNamespaceDocsResults - .filter(isLeft) - .map((expectedResult) => { - return { ...expectedResult.value, success: false }; - }); + // We only have error results; return early. + const savedObjects = expectedResults.map((expectedResult) => { + return { ...expectedResult.value, success: false }; + }); return { statuses: [...savedObjects] }; } @@ -166,10 +179,11 @@ export const performBulkDelete = async ( let errorResult: BulkDeleteItemErrorResult; const objectsToDeleteAliasesFor: ObjectToDeleteAliasesFor[] = []; - const savedObjects = expectedBulkDeleteMultiNamespaceDocsResults.map((expectedResult) => { + const savedObjects = expectedResults.map((expectedResult) => { if (isLeft(expectedResult)) { return { ...expectedResult.value, success: false }; } + const { type, id, namespaces, esRequestIndex: esBulkDeleteRequestIndex } = expectedResult.value; // we assume this wouldn't happen but is needed to ensure type consistency if (bulkDeleteResponse === undefined) { @@ -284,7 +298,12 @@ function getExpectedBulkDeleteMultiNamespaceDocsResults( if (isLeft(expectedBulkGetResult)) { return { ...expectedBulkGetResult }; } - const { esRequestIndex: esBulkGetRequestIndex, id, type } = expectedBulkGetResult.value; + const { + esRequestIndex: esBulkGetRequestIndex, + id, + type, + accessControl, + } = expectedBulkGetResult.value; let namespaces; @@ -338,6 +357,7 @@ function getExpectedBulkDeleteMultiNamespaceDocsResults( type, id, namespaces, + ...(accessControl ? { accessControl } : {}), esRequestIndex: indexCounter++, }; diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/bulk_get.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/bulk_get.ts index 8d0dce9b1139f..fc5e1310db604 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/bulk_get.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/bulk_get.ts @@ -15,24 +15,24 @@ import type { SavedObjectsRawDocSource, AuthorizeBulkGetObject, } from '@kbn/core-saved-objects-server'; -import { SavedObjectsErrorHelpers, type SavedObject } from '@kbn/core-saved-objects-server'; -import { ALL_NAMESPACES_STRING, SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server'; -import type { - SavedObjectsBulkGetObject, - SavedObjectsBulkResponse, - SavedObjectsGetOptions, -} from '@kbn/core-saved-objects-api-server'; -import { includedFields } from '../utils'; -import type { Either } from './utils'; import { + SavedObjectsErrorHelpers, errorContent, - getSavedObjectFromSource, + type SavedObject, +} from '@kbn/core-saved-objects-server'; +import { ALL_NAMESPACES_STRING, SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server'; +import { isLeft, isRight, left, right, - rawDocExistsInNamespaces, -} from './utils'; + type Either, + type SavedObjectsBulkGetObject, + type SavedObjectsBulkResponse, + type SavedObjectsGetOptions, +} from '@kbn/core-saved-objects-api-server'; +import { includedFields } from '../utils'; +import { getSavedObjectFromSource, rawDocExistsInNamespaces } from './utils'; import type { ApiExecutionContext } from './types'; export interface PerformBulkGetParams { diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/bulk_resolve.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/bulk_resolve.ts index eb965c19278a3..06b36c7575143 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/bulk_resolve.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/bulk_resolve.ts @@ -7,7 +7,11 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { BulkResolveError } from '@kbn/core-saved-objects-server'; +import { + type BulkResolveError, + errorContent, + SavedObjectsErrorHelpers, +} from '@kbn/core-saved-objects-server'; import { type SavedObject } from '@kbn/core-saved-objects-server'; import type { SavedObjectsBulkResolveObject, @@ -15,9 +19,8 @@ import type { SavedObjectsResolveOptions, SavedObjectsResolveResponse, } from '@kbn/core-saved-objects-api-server'; -import { errorContent } from './utils'; import type { ApiExecutionContext } from './types'; -import { internalBulkResolve, isBulkResolveError } from './internals/internal_bulk_resolve'; +import { internalBulkResolve } from './internals/internal_bulk_resolve'; import { incrementCounterInternal } from './internals/increment_counter_internal'; export interface PerformCreateParams { @@ -44,7 +47,7 @@ export const performBulkResolve = async ( const resolvedObjects = bulkResults.map>((result) => { // extract payloads from saved object errors - if (isBulkResolveError(result)) { + if (SavedObjectsErrorHelpers.isBulkResolveError(result)) { const errorResult = result as BulkResolveError; const { type, id, error } = errorResult; return { diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/bulk_update.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/bulk_update.ts index ae745d1786013..82489143dd67f 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/bulk_update.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/bulk_update.ts @@ -17,26 +17,29 @@ import type { SavedObjectSanitizedDoc, WithAuditName, } from '@kbn/core-saved-objects-server'; -import { SavedObjectsErrorHelpers, type SavedObject } from '@kbn/core-saved-objects-server'; +import { + SavedObjectsErrorHelpers, + errorContent, + type SavedObject, +} from '@kbn/core-saved-objects-server'; import { ALL_NAMESPACES_STRING, SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server'; import { encodeVersion } from '@kbn/core-saved-objects-base-server-internal'; -import type { - SavedObjectsBulkUpdateObject, - SavedObjectsBulkUpdateOptions, - SavedObjectsBulkUpdateResponse, +import { + isLeft, + isRight, + left, + right, + type Either, + type SavedObjectsBulkUpdateObject, + type SavedObjectsBulkUpdateOptions, + type SavedObjectsBulkUpdateResponse, } from '@kbn/core-saved-objects-api-server'; import { DEFAULT_REFRESH_SETTING } from '../constants'; import { - type Either, - errorContent, getBulkOperationError, getCurrentTime, getExpectedVersionProperties, isMgetDoc, - left, - right, - isLeft, - isRight, rawDocExistsInNamespace, getSavedObjectFromSource, mergeForUpdate, @@ -191,12 +194,19 @@ export const performBulkUpdate = async ( attributes: documentToSave[type] as SavedObject, }); + const accessControl = + registry.supportsAccessControl(type) && securityExtension + ? // @ts-expect-error MultiGetHit._source is optional + preflightResult._source?.accessControl + : undefined; + if (registry.isMultiNamespace(type)) { return { type, id, objectNamespace, name, + ...(accessControl && { accessControl }), // @ts-expect-error MultiGetHit._source is optional existingNamespaces: preflightResult._source?.namespaces ?? [], }; @@ -206,6 +216,7 @@ export const performBulkUpdate = async ( id, objectNamespace, name, + ...(accessControl && { accessControl }), existingNamespaces: [], }; } @@ -216,139 +227,154 @@ export const performBulkUpdate = async ( objects: authObjects, }); + const inaccessibleObjects = authorizationResult?.inaccessibleObjects + ? Array.from(authorizationResult.inaccessibleObjects) + : []; + + const expectedAuthorizedResults = await securityExtension?.filterInaccessibleObjectsForBulkAction( + expectedBulkGetResults, + inaccessibleObjects, + 'bulk_update' + ); + let bulkUpdateRequestIndexCounter = 0; const bulkUpdateParams: object[] = []; const expectedBulkUpdateResults = await Promise.all( - expectedBulkGetResults.map>(async (expectedBulkGetResult) => { - if (isLeft(expectedBulkGetResult)) { - return expectedBulkGetResult; - } + (expectedAuthorizedResults ?? expectedBulkGetResults).map>( + async (expectedBulkGetResult) => { + if (isLeft(expectedBulkGetResult)) { + return expectedBulkGetResult; + } - const { - esRequestIndex, - id, - type, - version, - documentToSave, - objectNamespace, - mergeAttributes, - } = expectedBulkGetResult.value; - - const versionProperties = getExpectedVersionProperties(version); - const indexFound = bulkGetResponse?.statusCode !== 404; - const actualResult = indexFound ? bulkGetResponse?.body.docs[esRequestIndex] : undefined; - const docFound = indexFound && isMgetDoc(actualResult) && actualResult.found; - const isMultiNS = registry.isMultiNamespace(type); - - if ( - !docFound || - (isMultiNS && - !rawDocExistsInNamespace( - registry, - actualResult as SavedObjectsRawDoc, - getNamespaceId(objectNamespace) - )) - ) { - return left({ + const { + esRequestIndex, id, type, - error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), - }); - } + version, + documentToSave, + objectNamespace, + mergeAttributes, + } = expectedBulkGetResult.value; + + const versionProperties = getExpectedVersionProperties(version); + const indexFound = bulkGetResponse?.statusCode !== 404; + const actualResult = indexFound ? bulkGetResponse?.body.docs[esRequestIndex] : undefined; + const docFound = indexFound && isMgetDoc(actualResult) && actualResult.found; + const isMultiNS = registry.isMultiNamespace(type); + + if ( + !docFound || + (isMultiNS && + !rawDocExistsInNamespace( + registry, + actualResult as SavedObjectsRawDoc, + getNamespaceId(objectNamespace) + )) + ) { + return left({ + id, + type, + error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), + }); + } - let savedObjectNamespace: string | undefined; - let savedObjectNamespaces: string[] | undefined; + let savedObjectNamespace: string | undefined; + let savedObjectNamespaces: string[] | undefined; - if (isMultiNS) { - // @ts-expect-error MultiGetHit is incorrectly missing _id, _source - savedObjectNamespaces = actualResult!._source.namespaces ?? [ + if (isMultiNS) { // @ts-expect-error MultiGetHit is incorrectly missing _id, _source - SavedObjectsUtils.namespaceIdToString(actualResult!._source.namespace), - ]; - } else if (registry.isSingleNamespace(type)) { - // if `objectNamespace` is undefined, fall back to `options.namespace` - savedObjectNamespace = getNamespaceId(objectNamespace); - } - - const document = getSavedObjectFromSource( - registry, - type, - id, - actualResult as SavedObjectsRawDoc, - { migrationVersionCompatibility } - ); + savedObjectNamespaces = actualResult!._source.namespaces ?? [ + // @ts-expect-error MultiGetHit is incorrectly missing _id, _source + SavedObjectsUtils.namespaceIdToString(actualResult!._source.namespace), + ]; + } else if (registry.isSingleNamespace(type)) { + // if `objectNamespace` is undefined, fall back to `options.namespace` + savedObjectNamespace = getNamespaceId(objectNamespace); + } - let migrated: SavedObject; - try { - migrated = migrationHelper.migrateStorageDocument(document) as SavedObject; - } catch (migrateStorageDocError) { - throw SavedObjectsErrorHelpers.decorateGeneralError( - migrateStorageDocError, - 'Failed to migrate document to the latest version.' + const document = getSavedObjectFromSource( + registry, + type, + id, + actualResult as SavedObjectsRawDoc, + { migrationVersionCompatibility } ); - } - const typeDefinition = registry.getType(type)!; + let migrated: SavedObject; + try { + migrated = migrationHelper.migrateStorageDocument(document) as SavedObject; + } catch (migrateStorageDocError) { + throw SavedObjectsErrorHelpers.decorateGeneralError( + migrateStorageDocError, + 'Failed to migrate document to the latest version.' + ); + } - const encryptedUpdatedAttributes = await encryptionHelper.optionallyEncryptAttributes( - type, - id, - objectNamespace || namespace, - documentToSave[type] - ); - - const updatedAttributes = mergeAttributes - ? mergeForUpdate({ - targetAttributes: { - ...(migrated!.attributes as Record), - }, - updatedAttributes: encryptedUpdatedAttributes, - typeMappings: typeDefinition.mappings, - }) - : encryptedUpdatedAttributes; + const typeDefinition = registry.getType(type)!; - const migratedUpdatedSavedObjectDoc = migrationHelper.migrateInputDocument({ - ...migrated!, - id, - type, - ...(savedObjectNamespace && { namespace: savedObjectNamespace }), - ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), - attributes: updatedAttributes, - updated_at: time, - updated_by: updatedBy, - ...(Array.isArray(documentToSave.references) && { references: documentToSave.references }), - }); - const updatedMigratedDocumentToSave = serializer.savedObjectToRaw( - migratedUpdatedSavedObjectDoc as SavedObjectSanitizedDoc - ); - - const namespaces = - savedObjectNamespaces ?? (savedObjectNamespace ? [savedObjectNamespace] : []); - - const expectedResult = { - type, - id, - namespaces, - esRequestIndex: bulkUpdateRequestIndexCounter++, - documentToSave: expectedBulkGetResult.value.documentToSave, - rawMigratedUpdatedDoc: updatedMigratedDocumentToSave, - migrationVersionCompatibility, - }; + const encryptedUpdatedAttributes = await encryptionHelper.optionallyEncryptAttributes( + type, + id, + objectNamespace || namespace, + documentToSave[type] + ); + + const updatedAttributes = mergeAttributes + ? mergeForUpdate({ + targetAttributes: { + ...(migrated!.attributes as Record), + }, + updatedAttributes: encryptedUpdatedAttributes, + typeMappings: typeDefinition.mappings, + }) + : encryptedUpdatedAttributes; + + const migratedUpdatedSavedObjectDoc = migrationHelper.migrateInputDocument({ + ...migrated!, + id, + type, + ...(savedObjectNamespace && { namespace: savedObjectNamespace }), + ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), + attributes: updatedAttributes, + updated_at: time, + updated_by: updatedBy, + ...(migrated.accessControl ? { accessControl: migrated.accessControl } : {}), + ...(Array.isArray(documentToSave.references) && { + references: documentToSave.references, + }), + }); + const updatedMigratedDocumentToSave = serializer.savedObjectToRaw( + migratedUpdatedSavedObjectDoc as SavedObjectSanitizedDoc + ); + + const namespaces = + savedObjectNamespaces ?? (savedObjectNamespace ? [savedObjectNamespace] : []); - bulkUpdateParams.push( - { - index: { - _id: serializer.generateRawId(getNamespaceId(objectNamespace), type, id), - _index: commonHelper.getIndexForType(type), - ...versionProperties, + const expectedResult = { + type, + id, + namespaces, + esRequestIndex: bulkUpdateRequestIndexCounter++, + documentToSave: expectedBulkGetResult.value.documentToSave, + rawMigratedUpdatedDoc: updatedMigratedDocumentToSave, + migrationVersionCompatibility, + }; + + bulkUpdateParams.push( + { + index: { + _id: serializer.generateRawId(getNamespaceId(objectNamespace), type, id), + _index: commonHelper.getIndexForType(type), + ...versionProperties, + }, }, - }, - updatedMigratedDocumentToSave._source - ); + updatedMigratedDocumentToSave._source + ); - return right(expectedResult); - }) + return right(expectedResult); + } + ) ); const { refresh = DEFAULT_REFRESH_SETTING } = options; @@ -397,6 +423,9 @@ export const performBulkUpdate = async ( ...(originId && { originId }), updated_at, updated_by, + ...(registry.supportsAccessControl(type) && { + accessControl: rawMigratedUpdatedDoc._source.accessControl, + }), version: encodeVersion(seqNo, primaryTerm), attributes, references, diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/change_access_mode.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/change_access_mode.ts new file mode 100644 index 0000000000000..85b5ab66603c7 --- /dev/null +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/change_access_mode.ts @@ -0,0 +1,56 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { + SavedObjectsChangeAccessControlObject, + SavedObjectsChangeAccessModeOptions, + SavedObjectsChangeAccessControlResponse, +} from '@kbn/core-saved-objects-api-server'; + +import { + changeObjectAccessControl, + isSavedObjectsChangeAccessModeOptions, +} from './internals/change_object_access_control'; +import type { ApiExecutionContext } from './types'; + +export interface PerformChangeAccessModeParams { + objects: SavedObjectsChangeAccessControlObject[]; + options: SavedObjectsChangeAccessModeOptions; +} + +export const performChangeAccessMode = async ( + { objects, options }: PerformChangeAccessModeParams, + { registry, helpers, allowedTypes, client, serializer, extensions = {} }: ApiExecutionContext +): Promise => { + const { common: commonHelper, user: userHelper } = helpers; + const { securityExtension } = extensions; + const currentUserProfileUid = userHelper.getCurrentUserProfileUid(); + + if (!currentUserProfileUid) { + throw new Error('Unexpected error in changeAccessMode: currentUserProfile is undefined.'); + } + + if (!isSavedObjectsChangeAccessModeOptions(options)) { + throw new Error('Unexpected error in changeAccessMode: invalid options'); + } + + const namespace = commonHelper.getCurrentNamespace(options.namespace); + return changeObjectAccessControl({ + registry, + allowedTypes, + client, + serializer, + getIndexForType: commonHelper.getIndexForType.bind(commonHelper), + objects, + options: { ...options, namespace }, + securityExtension, + actionType: 'changeAccessMode', + currentUserProfileUid, + }); +}; diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/change_ownership.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/change_ownership.ts new file mode 100644 index 0000000000000..1ab75b1206d5b --- /dev/null +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/change_ownership.ts @@ -0,0 +1,56 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { + SavedObjectsChangeAccessControlObject, + SavedObjectsChangeAccessControlResponse, + SavedObjectsChangeOwnershipOptions, +} from '@kbn/core-saved-objects-api-server'; + +import { + changeObjectAccessControl, + isSavedObjectsChangeOwnershipOptions, +} from './internals/change_object_access_control'; +import type { ApiExecutionContext } from './types'; + +export interface PerformChangeOwnershipParams { + objects: SavedObjectsChangeAccessControlObject[]; + options: SavedObjectsChangeOwnershipOptions; +} + +export const performChangeOwnership = async ( + { objects, options }: PerformChangeOwnershipParams, + { registry, helpers, allowedTypes, client, serializer, extensions = {} }: ApiExecutionContext +): Promise => { + const { common: commonHelper, user: userHelper } = helpers; + const { securityExtension } = extensions; + const currentUserProfileUid = userHelper.getCurrentUserProfileUid(); + + if (!currentUserProfileUid) { + throw new Error('Unexpected error in changeOwnership: currentUserProfile is undefined.'); + } + + if (!isSavedObjectsChangeOwnershipOptions(options)) { + throw new Error('Unexpected error in changeOwnership: invalid options'); + } + + const namespace = commonHelper.getCurrentNamespace(options.namespace); + return changeObjectAccessControl({ + registry, + allowedTypes, + client, + serializer, + getIndexForType: commonHelper.getIndexForType.bind(commonHelper), + objects, + options: { ...options, namespace }, + securityExtension, + actionType: 'changeOwnership', + currentUserProfileUid, + }); +}; diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/check_conflicts.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/check_conflicts.ts index 012e954d1bc1d..37969232ac751 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/check_conflicts.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/check_conflicts.ts @@ -10,22 +10,18 @@ import type { Payload } from '@hapi/boom'; import { isNotFoundFromUnsupportedServer } from '@kbn/core-elasticsearch-server-internal'; import type { SavedObjectsRawDocSource, SavedObjectsRawDoc } from '@kbn/core-saved-objects-server'; -import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; -import type { - SavedObjectsCheckConflictsObject, - SavedObjectsBaseOptions, - SavedObjectsCheckConflictsResponse, -} from '@kbn/core-saved-objects-api-server'; -import type { Either } from './utils'; +import { SavedObjectsErrorHelpers, errorContent } from '@kbn/core-saved-objects-server'; import { - errorContent, + type SavedObjectsCheckConflictsObject, + type SavedObjectsBaseOptions, + type SavedObjectsCheckConflictsResponse, + type Either, left, right, - isLeft, isRight, - isMgetDoc, - rawDocExistsInNamespace, -} from './utils'; + isLeft, +} from '@kbn/core-saved-objects-api-server'; +import { isMgetDoc, rawDocExistsInNamespace } from './utils'; import type { ApiExecutionContext } from './types'; export interface PerformCheckConflictsParams { @@ -99,6 +95,7 @@ export const performCheckConflicts = async ( const errors: SavedObjectsCheckConflictsResponse['errors'] = []; expectedBulkGetResults.forEach((expectedResult) => { + // Unsupported type if (isLeft(expectedResult)) { errors.push(expectedResult.value as any); return; diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/create.test.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/create.test.ts index dd724c889fee0..ec2c7db8e9535 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/create.test.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/create.test.ts @@ -33,6 +33,7 @@ import { kibanaMigratorMock } from '../../mocks'; import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; import { savedObjectsExtensionsMock } from '../../mocks/saved_objects_extensions.mock'; import type { ISavedObjectsSecurityExtension } from '@kbn/core-saved-objects-server'; +import { mockAuthenticatedUser } from '@kbn/core-security-common/mocks'; import { CUSTOM_INDEX_TYPE, @@ -51,6 +52,7 @@ import { createUnsupportedTypeErrorPayload, createConflictErrorPayload, mockTimestampFieldsWithCreated, + ACCESS_CONTROL_TYPE, } from '../../test_helpers/repository.test.common'; describe('#create', () => { @@ -62,6 +64,7 @@ describe('#create', () => { let securityExtension: jest.Mocked; const registry = createRegistry(); + const documentMigrator = createDocumentMigrator(registry); const expectMigrationArgs = (args: unknown, contains = true, n = 1) => { @@ -851,5 +854,101 @@ describe('#create', () => { ); }); }); + + describe('access control', () => { + it('should not allow creating an object with access control when the type does not support access control', async () => { + await expect( + repository.create(MULTI_NAMESPACE_TYPE, attributes, { + id, + namespace, + accessControl: { + accessMode: 'write_restricted', + }, + }) + ).rejects.toThrowError( + createBadRequestErrorPayload( + `Cannot create a saved object of type multiNamespaceType with an access mode because the type does not support access control` + ) + ); + expect(client.create).not.toHaveBeenCalled(); + }); + + it('allows creation of an object with access control when the type supports it', async () => { + securityExtension.getCurrentUser.mockReturnValue( + mockAuthenticatedUser({ profile_uid: 'u_test_user_version' }) + ); + const accessControl = { + accessMode: 'write_restricted' as const, + }; + + const result = await repository.create(ACCESS_CONTROL_TYPE, attributes, { + id, + namespace, + references, + accessControl, + }); + expect(result).toEqual({ + type: ACCESS_CONTROL_TYPE, + id, + ...mockTimestampFieldsWithCreated, + version: mockVersion, + attributes, + references, + namespaces: [namespace ?? 'default'], + coreMigrationVersion: expect.any(String), + typeMigrationVersion: '1.1.1', + managed: false, + updated_by: 'u_test_user_version', + created_by: 'u_test_user_version', + accessControl: { + accessMode: 'write_restricted', + owner: 'u_test_user_version', + }, + }); + }); + + it('throws when trying to create an object with access control and there is no active user profile', async () => { + securityExtension.getCurrentUser.mockReturnValueOnce(null); + await expect( + repository.create(ACCESS_CONTROL_TYPE, attributes, { + id, + namespace, + references, + accessControl: { + accessMode: 'write_restricted', + }, + }) + ).rejects.toThrowError( + createBadRequestErrorPayload( + `Cannot create a saved object of type accessControlType with an access mode because Kibana could not determine the user profile ID for the caller. Access control requires an identifiable user profile` + ) + ); + expect(client.create).not.toHaveBeenCalled(); + }); + + // Regression test + it('allows creation when the type supports access control and no user is present if access mode is not provided', async () => { + securityExtension.getCurrentUser.mockReturnValueOnce(null); + + const result = await repository.create(ACCESS_CONTROL_TYPE, attributes, { + id, + namespace, + references, + }); + + expect(result).toEqual({ + type: ACCESS_CONTROL_TYPE, + id, + ...mockTimestampFieldsWithCreated, + version: mockVersion, + attributes, + references, + namespaces: [namespace ?? 'default'], + coreMigrationVersion: expect.any(String), + typeMigrationVersion: '1.1.1', + managed: false, + }); + }); + }); }); }); diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/create.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/create.ts index 2653fcfca50cb..7ddf01e3359a3 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/create.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/create.ts @@ -15,13 +15,17 @@ import { } from '@kbn/core-saved-objects-server'; import { SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server'; import { decodeRequestVersion } from '@kbn/core-saved-objects-base-server-internal'; -import type { SavedObjectsCreateOptions } from '@kbn/core-saved-objects-api-server'; +import type { + SavedObjectAccessControl, + SavedObjectsCreateOptions, +} from '@kbn/core-saved-objects-api-server'; import type { CreateRequest } from '@elastic/elasticsearch/lib/api/types'; import { type IndexRequest } from '@elastic/elasticsearch/lib/api/types'; import { DEFAULT_REFRESH_SETTING } from '../constants'; import type { PreflightCheckForCreateResult } from './internals/preflight_check_for_create'; import { getSavedObjectNamespaces, getCurrentTime, normalizeNamespace, setManaged } from './utils'; import type { ApiExecutionContext } from './types'; +import { setAccessControl } from './utils/internal_utils'; export interface PerformCreateParams { type: string; @@ -103,6 +107,30 @@ export const performCreate = async ( existingOriginId = preflightResult?.existingDocument?._source?.originId; } + const accessMode = options.accessControl?.accessMode; + const typeSupportsAccessControl = registry.supportsAccessControl(type); + let accessControlToWrite: SavedObjectAccessControl | undefined; + if (securityExtension) { + if (!typeSupportsAccessControl && accessMode) { + throw SavedObjectsErrorHelpers.createBadRequestError( + `Cannot create a saved object of type ${type} with an access mode because the type does not support access control` + ); + } + + if (!createdBy && accessMode) { + throw SavedObjectsErrorHelpers.createBadRequestError( + `Cannot create a saved object of type ${type} with an access mode because Kibana could not determine the user profile ID for the caller. Access control requires an identifiable user profile` + ); + } + accessControlToWrite = + preflightResult?.existingDocument?._source?.accessControl ?? + setAccessControl({ + typeSupportsAccessControl, + createdBy, + accessMode, + }); + } + const authorizationResult = await securityExtension?.authorizeCreate({ namespace, object: { @@ -110,6 +138,7 @@ export const performCreate = async ( id, initialNamespaces, existingNamespaces: preflightResult?.existingDocument?._source?.namespaces ?? [], + accessControl: accessControlToWrite, name: SavedObjectsUtils.getName(registry.getNameAttribute(type), { attributes: { ...(preflightResult?.existingDocument?._source?.[type] ?? {}), @@ -148,6 +177,7 @@ export const performCreate = async ( ...(createdBy && { created_by: createdBy }), ...(updatedBy && { updated_by: updatedBy }), ...(Array.isArray(references) && { references }), + ...(accessControlToWrite && { accessControl: accessControlToWrite }), }); /** diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/delete.test.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/delete.test.ts index a7941f41efe12..d25d063f9b0cc 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/delete.test.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/delete.test.ts @@ -111,7 +111,7 @@ describe('#delete', () => { it(`should use ES get action then delete action when using a multi-namespace type`, async () => { await deleteSuccess(client, repository, registry, MULTI_NAMESPACE_ISOLATED_TYPE, id); - expect(client.get).toHaveBeenCalledTimes(1); + expect(client.get).not.toHaveBeenCalled(); expect(client.delete).toHaveBeenCalledTimes(1); }); @@ -228,7 +228,7 @@ describe('#delete', () => { }); it(`logs a message when deleteLegacyUrlAliases returns an error`, async () => { - client.get.mockResolvedValueOnce( + client.get.mockResolvedValue( elasticsearchClientMock.createSuccessTransportRequestPromise( getMockGetResponse(registry, { type: MULTI_NAMESPACE_ISOLATED_TYPE, id, namespace }) ) @@ -240,11 +240,12 @@ describe('#delete', () => { ); mockDeleteLegacyUrlAliases.mockRejectedValueOnce(new Error('Oh no!')); await repository.delete(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }); - expect(client.get).toHaveBeenCalledTimes(1); + expect(client.get).toHaveBeenCalledTimes(2); expect(logger.error).toHaveBeenCalledTimes(1); expect(logger.error).toHaveBeenCalledWith( 'Unable to delete aliases when deleting an object: Oh no!' ); + client.get.mockClear(); }); }); @@ -282,7 +283,7 @@ describe('#delete', () => { } as estypes.GetResponse) ); await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); - expect(client.get).toHaveBeenCalledTimes(1); + expect(client.get).toHaveBeenCalledTimes(2); }); it(`throws when ES is unable to find the index during get`, async () => { @@ -292,7 +293,7 @@ describe('#delete', () => { }) ); await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); - expect(client.get).toHaveBeenCalledTimes(1); + expect(client.get).toHaveBeenCalledTimes(2); }); it(`throws when the type is multi-namespace and the document exists, but not in this namespace`, async () => { @@ -307,7 +308,7 @@ describe('#delete', () => { await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace: 'bar-namespace', }); - expect(client.get).toHaveBeenCalledTimes(1); + expect(client.get).toHaveBeenCalledTimes(2); }); it(`throws when the type is multi-namespace and the document has multiple namespaces and the force option is not enabled`, async () => { @@ -317,15 +318,15 @@ describe('#delete', () => { namespace, }); response._source!.namespaces = [namespace, 'bar-namespace']; - client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise(response) - ); + client.get.mockResponse(response); + await expect( repository.delete(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }) ).rejects.toThrowError( 'Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway' ); - expect(client.get).toHaveBeenCalledTimes(1); + expect(client.get).toHaveBeenCalledTimes(2); + client.get.mockClear(); }); it(`throws when the type is multi-namespace and the document has all namespaces and the force option is not enabled`, async () => { @@ -334,16 +335,17 @@ describe('#delete', () => { id, namespace, }); + response._source!.namespaces = ['*']; - client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise(response) - ); + client.get.mockResponse(response); + await expect( repository.delete(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }) ).rejects.toThrowError( 'Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway' ); - expect(client.get).toHaveBeenCalledTimes(1); + expect(client.get).toHaveBeenCalledTimes(2); + client.get.mockClear(); }); it(`throws when ES is unable to find the document during delete`, async () => { @@ -422,7 +424,7 @@ describe('#delete', () => { await repository.delete(type, id, { namespace }); - expect(client.get).toHaveBeenCalledTimes(0); + expect(client.get).toHaveBeenCalledTimes(1); expect(securityExtension.authorizeDelete).toHaveBeenLastCalledWith({ namespace, diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/delete.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/delete.ts index 1ef4698371061..20f6219c2a437 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/delete.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/delete.ts @@ -49,30 +49,30 @@ export const performDelete = async ( const { refresh = DEFAULT_REFRESH_SETTING, force } = options; if (securityExtension) { - let name; - - if (securityExtension.includeSavedObjectNames()) { - const nameAttribute = registry.getNameAttribute(type); - - const savedObjectResponse = await client.get( - { - index: commonHelper.getIndexForType(type), - id: serializer.generateRawId(namespace, type, id), - _source_includes: SavedObjectsUtils.getIncludedNameFields(type, nameAttribute), - }, - { ignore: [404], meta: true } - ); - - const saveObject = { attributes: savedObjectResponse.body._source?.[type] }; - - name = SavedObjectsUtils.getName(nameAttribute, saveObject); - } - + const nameAttribute = registry.getNameAttribute(type); + + const savedObjectResponse = await client.get( + { + index: commonHelper.getIndexForType(type), + id: serializer.generateRawId(namespace, type, id), + _source_includes: [ + ...SavedObjectsUtils.getIncludedNameFields(type, nameAttribute), + 'accessControl', + ], + }, + { ignore: [404], meta: true } + ); + + const saveObject = { attributes: savedObjectResponse.body._source?.[type] }; + const name = securityExtension.includeSavedObjectNames() + ? SavedObjectsUtils.getName(nameAttribute, saveObject) + : undefined; + const accessControl = savedObjectResponse.body._source?.accessControl; // we don't need to pass existing namespaces in because we're only concerned with authorizing // the current space. This saves us from performing the preflight check if we're unauthorized await securityExtension?.authorizeDelete({ namespace, - object: { type, id, name }, + object: { type, id, name, accessControl }, }); } diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/find.test.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/find.test.ts index 632276eec4f08..077d9d72916e3 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/find.test.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/find.test.ts @@ -146,6 +146,7 @@ describe('find', () => { 'coreMigrationVersion', 'typeMigrationVersion', 'managed', + 'accessControl', 'updated_at', 'updated_by', 'created_at', diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/helpers/preflight_check.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/helpers/preflight_check.ts index a60ba58c696e2..686a7514ecb41 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/helpers/preflight_check.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/helpers/preflight_check.ts @@ -12,22 +12,26 @@ import { isNotFoundFromUnsupportedServer } from '@kbn/core-elasticsearch-server- import type { ISavedObjectTypeRegistry, ISavedObjectsSerializer, - SavedObjectsRawDocSource, } from '@kbn/core-saved-objects-server'; import { SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server'; -import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; +import { + SavedObjectsErrorHelpers, + type SavedObjectsRawDocSource, +} from '@kbn/core-saved-objects-server'; +import { isRight } from '@kbn/core-saved-objects-api-server'; import type { RepositoryEsClient } from '../../repository_es_client'; import type { PreflightCheckForBulkDeleteParams } from '../internals/repository_bulk_delete_internal_types'; import type { CreatePointInTimeFinderFn } from '../../point_in_time_finder'; import { getSavedObjectNamespaces, - isRight, rawDocExistsInNamespaces, isFoundGetResponse, type GetResponseFound, } from '../utils'; -import type { PreflightCheckForCreateObject } from '../internals/preflight_check_for_create'; -import { preflightCheckForCreate } from '../internals/preflight_check_for_create'; +import { + preflightCheckForCreate, + type PreflightCheckForCreateObject, +} from '../internals/preflight_check_for_create'; export type IPreflightCheckHelper = PublicMethodsOf; @@ -83,7 +87,7 @@ export class PreflightCheckHelper { .map(({ value: { type, id, fields } }) => ({ _id: this.serializer.generateRawId(namespace, type, id), _index: this.getIndexForType(type), - _source: ['type', 'namespaces', ...(fields ?? [])], + _source: ['type', 'namespaces', 'accessControl', ...(fields ?? [])], })); const bulkGetMultiNamespaceDocsResponse = bulkGetMultiNamespaceDocs.length diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/change_object_access_control.test.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/change_object_access_control.test.ts new file mode 100644 index 0000000000000..6071bbd5b1646 --- /dev/null +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/change_object_access_control.test.ts @@ -0,0 +1,345 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { typeRegistryMock } from '@kbn/core-saved-objects-base-server-mocks'; + +import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; + +import { loggerMock } from '@kbn/logging-mocks'; +import type { + ISavedObjectsSecurityExtension, + SavedObjectAccessControl, +} from '@kbn/core-saved-objects-server'; +import { SavedObjectsSerializer } from '@kbn/core-saved-objects-base-server-internal'; +import { + type ChangeAccessControlParams, + changeObjectAccessControl, +} from './change_object_access_control'; +import { mockGetBulkOperationError } from './update_objects_spaces.test.mock'; +import { savedObjectsExtensionsMock } from '../../../mocks/saved_objects_extensions.mock'; + +jest.mock('../utils', () => ({ + getBulkOperationError: jest.fn(), + getExpectedVersionProperties: jest.fn(), + rawDocExistsInNamespace: jest.fn(), + isLeft: jest.requireActual('../utils').isLeft, + isRight: jest.requireActual('../utils').isRight, + left: jest.requireActual('../utils').left, + right: jest.requireActual('../utils').right, +})); + +type SetupParams = Partial>; + +const ACCESS_CONTROL_TYPE = 'access-control-type'; +const NON_ACCESS_CONTROL_TYPE = 'non-access-control-type'; + +const BULK_ERROR = { + error: 'Oh no, a bulk error!', + type: 'error_type', + message: 'error_message', + statusCode: 400, +}; + +const VERSION_PROPS = { _seq_no: 1, _primary_term: 1 }; +const mockSecurityExt = savedObjectsExtensionsMock.createSecurityExtension(); +const mockUserProfileId = 'u_mock_id'; + +describe('changeObjectAccessControl', () => { + let client: ReturnType; + + function setup( + { objects = [] }: SetupParams, + securityExtension?: ISavedObjectsSecurityExtension + ) { + const registry = typeRegistryMock.create(); + registry.supportsAccessControl.mockImplementation((type) => type === ACCESS_CONTROL_TYPE); + client = elasticsearchClientMock.createElasticsearchClient(); + const serializer = new SavedObjectsSerializer(registry); + + return { + mappings: { properties: {} }, // doesn't matter, only used as an argument to deleteLegacyUrlAliases which is mocked + registry, + allowedTypes: [ACCESS_CONTROL_TYPE, NON_ACCESS_CONTROL_TYPE], + client, + serializer, + logger: loggerMock.create(), + getIndexForType: (type: string) => `index-for-${type}`, + securityExtension: mockSecurityExt, + objects, + }; + } + + function mockMgetResults( + results: Array< + | { found: false } + | { + found: true; + namespaces: string[]; + type?: string; + id?: string; + accessControl?: SavedObjectAccessControl; + } + > + ) { + const result = results.map((x) => + x.found + ? { + _id: `${x.type ?? ACCESS_CONTROL_TYPE}:${x.id ?? 'id-unknown'}`, + _index: `index-for-${x.type ?? ACCESS_CONTROL_TYPE}`, + _source: { namespaces: x.namespaces, accessControl: x.accessControl }, + _seq_no: VERSION_PROPS._seq_no, + _primary_term: VERSION_PROPS._primary_term, + + found: true, + } + : { + _id: `unknown-type:${'id-unknown'}`, + _index: `index-for-unknown-type`, + found: false, + } + ); + + client.mget.mockResponseOnce({ docs: result }); + } + + function mockBulkResults(...results: Array<{ error: boolean }>) { + results.forEach(({ error }) => { + if (error) { + mockGetBulkOperationError.mockReturnValueOnce(BULK_ERROR); + } else { + mockGetBulkOperationError.mockReturnValueOnce(undefined); + } + }); + client.bulk.mockResponseOnce({ + items: results.map(() => ({})), // as long as the result does not contain an error field, it is treated as a success + errors: false, + took: 0, + }); + } + + beforeEach(() => { + mockGetBulkOperationError.mockReset(); // reset calls and return undefined by default + }); + describe('ownership changes', () => { + describe('validation', () => { + it('throws if owner is not specified', async () => { + const params = setup({ + objects: [{ type: ACCESS_CONTROL_TYPE, id: 'id-1' }], + }); + + await expect(() => + changeObjectAccessControl({ + ...params, + options: { + newOwnerProfileUid: undefined, + }, + actionType: 'changeOwnership', + currentUserProfileUid: '', + }) + ).rejects.toThrow( + 'The "newOwnerProfileUid" field is required to change ownership of a saved object.: Bad Request' + ); + }); + + it('throws if owner has invalid user profile id', async () => { + const params = setup({ + objects: [{ type: ACCESS_CONTROL_TYPE, id: 'id-1' }], + }); + + await expect(() => + changeObjectAccessControl({ + ...params, + options: { + newOwnerProfileUid: 'invalid_user_profile_id', + }, + actionType: 'changeOwnership', + currentUserProfileUid: mockUserProfileId, + }) + ).rejects.toThrow( + 'User profile ID is invalid: expected "u__": Bad Request' + ); + }); + + it('returns error if no access control objects are specified', async () => { + const params = setup({ + objects: [ + { type: NON_ACCESS_CONTROL_TYPE, id: 'id-1' }, + { type: NON_ACCESS_CONTROL_TYPE, id: 'id-2' }, + ], + }); + + const result = await changeObjectAccessControl({ + ...params, + options: { + newOwnerProfileUid: 'u_unittestuser_version', + }, + actionType: 'changeOwnership', + currentUserProfileUid: mockUserProfileId, + }); + expect(result.objects[0]).toHaveProperty('error'); + const error = result.objects[0].error; + expect(error).toBeTruthy(); + expect(error!.message).toBe( + `The type ${NON_ACCESS_CONTROL_TYPE} does not support access control: Bad Request` + ); + }); + }); + + describe('bulk and mget behavior', () => { + it('does not call bulk if no objects need to be updated', async () => { + const params = setup({ + objects: [{ type: NON_ACCESS_CONTROL_TYPE, id: 'id-1' }], + }); + mockMgetResults([{ found: true, namespaces: ['default'] }]); + const result = await changeObjectAccessControl({ + ...params, + options: { newOwnerProfileUid: 'u_unittestuser_version' }, + actionType: 'changeOwnership', + currentUserProfileUid: mockUserProfileId, + }); + expect(client.mget).not.toHaveBeenCalled(); + expect(client.bulk).not.toHaveBeenCalled(); + expect(result.objects[0]).toHaveProperty('error'); + }); + }); + + describe('authorization of operations', () => { + it('successfully delegates to security extension for change ownership', async () => { + const params = setup({ + objects: [{ type: ACCESS_CONTROL_TYPE, id: 'id-1' }], + }); + mockMgetResults([ + { + found: true, + namespaces: ['default'], + type: ACCESS_CONTROL_TYPE, + id: 'id-1', + accessControl: { + owner: 'new-owner', + accessMode: 'default', + }, + }, + ]); + mockBulkResults({ error: false }); + await changeObjectAccessControl({ + ...params, + securityExtension: params.securityExtension, + options: { newOwnerProfileUid: 'u_unittestuser_version', namespace: 'default' }, + actionType: 'changeOwnership', + currentUserProfileUid: mockUserProfileId, + }); + expect(mockSecurityExt.authorizeChangeAccessControl).toHaveBeenCalledWith( + { + namespace: 'default', + objects: [ + { + type: ACCESS_CONTROL_TYPE, + id: 'id-1', + accessControl: { + owner: 'new-owner', + accessMode: 'default', + }, + existingNamespaces: ['default'], + }, + ], + }, + 'changeOwnership' + ); + }); + }); + }); + + describe('change access mode', () => { + describe('validation', () => { + it('throws if access mode is not specified', async () => { + const params = setup({ + objects: [{ type: ACCESS_CONTROL_TYPE, id: 'id-1' }], + }); + + await expect(() => + changeObjectAccessControl({ + ...params, + options: { + accessMode: undefined, + }, + actionType: 'changeAccessMode', + currentUserProfileUid: '', + }) + ).rejects.toThrow( + 'The "accessMode" field is required to change access mode of a saved object.: Bad Request' + ); + }); + + it('returns error if no access control objects are specified', async () => { + const params = setup({ + objects: [{ type: NON_ACCESS_CONTROL_TYPE, id: 'id-1' }], + }); + + const result = await changeObjectAccessControl({ + ...params, + options: { + accessMode: 'write_restricted', + }, + actionType: 'changeAccessMode', + currentUserProfileUid: mockUserProfileId, + }); + expect(result.objects[0]).toHaveProperty('error'); + const error = result.objects[0].error; + expect(error).toBeTruthy(); + expect(error!.message).toBe( + `The type ${NON_ACCESS_CONTROL_TYPE} does not support access control: Bad Request` + ); + }); + }); + describe('authorization of operations', () => { + it('successfully delegates to security extension for change access mode', async () => { + const params = setup({ + objects: [{ type: ACCESS_CONTROL_TYPE, id: 'id-1' }], + }); + mockMgetResults([ + { + found: true, + namespaces: ['default'], + type: ACCESS_CONTROL_TYPE, + id: 'id-1', + accessControl: { + owner: 'new-owner', + accessMode: 'default', + }, + }, + ]); + mockBulkResults({ error: false }); + await changeObjectAccessControl({ + ...params, + securityExtension: params.securityExtension, + options: { accessMode: 'write_restricted', namespace: 'default' }, + actionType: 'changeAccessMode', + currentUserProfileUid: mockUserProfileId, + }); + expect(mockSecurityExt.authorizeChangeAccessControl).toHaveBeenCalledWith( + { + namespace: 'default', + objects: [ + { + type: ACCESS_CONTROL_TYPE, + id: 'id-1', + accessControl: { + owner: 'new-owner', + accessMode: 'default', + }, + existingNamespaces: ['default'], + }, + ], + }, + 'changeAccessMode' + ); + }); + }); + }); +}); diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/change_object_access_control.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/change_object_access_control.ts new file mode 100644 index 0000000000000..5f08e744571d9 --- /dev/null +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/change_object_access_control.ts @@ -0,0 +1,369 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ +import type { estypes } from '@elastic/elasticsearch'; +import { isNotFoundFromUnsupportedServer } from '@kbn/core-elasticsearch-server-internal'; +import { + type ISavedObjectTypeRegistry, + type ISavedObjectsSecurityExtension, + type ISavedObjectsSerializer, + SavedObjectsErrorHelpers, + type SavedObjectsRawDoc, + type SavedObjectsRawDocSource, +} from '@kbn/core-saved-objects-server'; +import { + type SavedObjectsChangeAccessControlResponse, + type SavedObjectsChangeAccessControlObject, + type SavedObjectsChangeAccessControlOptions, + type SavedObjectsChangeAccessModeOptions, + type SavedObjectsChangeOwnershipOptions, + type Either, + left, + right, + isRight, + isLeft, +} from '@kbn/core-saved-objects-api-server'; + +import { + getBulkOperationError, + getExpectedVersionProperties, + rawDocExistsInNamespace, +} from '../utils'; +import type { ApiExecutionContext } from '../types'; +import { type GetBulkOperationErrorRawResponse, isMgetError } from '../utils/internal_utils'; + +export type ChangeAccessControlActionType = 'changeOwnership' | 'changeAccessMode'; + +export interface ChangeAccessControlParams { + registry: ISavedObjectTypeRegistry; + allowedTypes: string[]; + client: ApiExecutionContext['client']; + serializer: ISavedObjectsSerializer; + getIndexForType: (type: string) => string; + objects: SavedObjectsChangeAccessControlObject[]; + options: SavedObjectsChangeAccessControlOptions; + securityExtension?: ISavedObjectsSecurityExtension; + actionType: ChangeAccessControlActionType; + currentUserProfileUid: string; +} + +// Regular expression to validate user profile IDs as generated by Elasticsearch +// User profile IDs are expected to be in the format "u__" +const USER_PROFILE_REGEX = /^u_.+_.+$/; + +const isValidUserProfileId = (userProfileId: string): boolean => { + return USER_PROFILE_REGEX.test(userProfileId); +}; + +export const isSavedObjectsChangeAccessModeOptions = ( + options: SavedObjectsChangeAccessControlOptions +): options is SavedObjectsChangeAccessModeOptions => { + return 'accessMode' in options; +}; + +export const isSavedObjectsChangeOwnershipOptions = ( + options: SavedObjectsChangeAccessControlOptions +): options is SavedObjectsChangeOwnershipOptions => { + return 'newOwnerProfileUid' in options; +}; + +const VALID_ACCESS_MODES = ['default', 'write_restricted'] as const; +type AccessMode = (typeof VALID_ACCESS_MODES)[number]; + +const validateChangeAccessControlParams = ({ + actionType, + newOwnerProfileUid, + accessMode, + objects, +}: { + actionType: ChangeAccessControlActionType; + newOwnerProfileUid?: string; + accessMode?: 'default' | 'write_restricted'; + objects: SavedObjectsChangeAccessControlObject[]; +}) => { + if (actionType === 'changeOwnership') { + if (!newOwnerProfileUid) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'The "newOwnerProfileUid" field is required to change ownership of a saved object.' + ); + } + + if (!isValidUserProfileId(newOwnerProfileUid)) { + throw SavedObjectsErrorHelpers.createBadRequestError( + `User profile ID is invalid: expected "u__"` + ); + } + } + if (actionType === 'changeAccessMode' && accessMode === undefined) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'The "accessMode" field is required to change access mode of a saved object.' + ); + } + if ( + actionType === 'changeAccessMode' && + accessMode !== undefined && + !VALID_ACCESS_MODES.includes(accessMode as AccessMode) + ) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'When specified, the "accessMode" field can only be "default" or "write_restricted".' + ); + } + + if (!objects || objects.length === 0) { + throw SavedObjectsErrorHelpers.createBadRequestError( + `No objects specified for ${ + actionType === 'changeOwnership' ? 'ownership change' : 'access mode change' + }` + ); + } +}; + +export const changeObjectAccessControl = async ( + params: ChangeAccessControlParams +): Promise => { + const { namespace } = params.options; + const { actionType, currentUserProfileUid } = params; + + // Extract owner for changeOwnership or accessMode for changeAccessMode + const newOwnerProfileUid = + actionType === 'changeOwnership' && isSavedObjectsChangeOwnershipOptions(params.options) + ? params.options.newOwnerProfileUid + : undefined; + const accessMode = + actionType === 'changeAccessMode' && isSavedObjectsChangeAccessModeOptions(params.options) + ? params.options.accessMode + : undefined; + const { + registry, + allowedTypes, + client, + serializer, + getIndexForType, + objects, + securityExtension, + } = params; + + if (!securityExtension) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'Unable to proceed with changing access control without security extension.' + ); + } + + validateChangeAccessControlParams({ + actionType, + newOwnerProfileUid, + accessMode, + objects, + }); + + /** + * We create a list of expected bulk get results, which will be used to + * perform the authorization checks and to build the bulk operation request. + * Valid objects will then be used for rewriting back to the index once authz and access control + * checks are done and valid. + */ + + const expectedBulkGetResults: Array< + Either< + { id: string; type: string; error: any }, + { id: string; type: string; esRequestIndex: number } + > + > = objects.map((object, index) => { + const { type, id } = object; + + if (!allowedTypes.includes(type)) { + const error = SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + return left({ id, type, error }); + } + if (!registry.supportsAccessControl(type)) { + const error = SavedObjectsErrorHelpers.createBadRequestError( + `The type ${type} does not support access control` + ); + return left({ id, type, error }); + } + + return right({ + type, + id, + esRequestIndex: index, + }); + }); + + const validObjects = expectedBulkGetResults.filter(isRight); + if (validObjects.length === 0) { + // We only have error results; return early to avoid potentially trying authZ checks for 0 types which would result in an exception. + return { + objects: expectedBulkGetResults.map(({ value }) => value), + }; + } + + // Only get the objects that are required for processing + const bulkGetDocs = validObjects.map((x) => ({ + _id: serializer.generateRawId(undefined, x.value.type, x.value.id), + _index: getIndexForType(x.value.type), + _source: ['type', 'namespaces', 'accessControl'], + })); + + const bulkGetResponse = await client.mget( + { docs: bulkGetDocs }, + { ignore: [404], meta: true } + ); + + if ( + bulkGetResponse && + isNotFoundFromUnsupportedServer({ + statusCode: bulkGetResponse.statusCode, + headers: bulkGetResponse.headers, + }) + ) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); + } + + const authObjects = validObjects.map((element) => { + const { type, id, esRequestIndex: index } = element.value; + + const preflightResult = bulkGetResponse.body.docs[ + index + ] as estypes.GetGetResult; + + return { + type, + id, + existingNamespaces: preflightResult?._source?.namespaces ?? [], + accessControl: preflightResult?._source?.accessControl, + }; + }); + + const authorizationResult = await securityExtension.authorizeChangeAccessControl( + { + namespace, + objects: authObjects, + }, + actionType + ); + + const time = new Date().toISOString(); + let bulkOperationRequestIndexCounter = 0; + const bulkOperationParams: estypes.BulkRequest['operations'] = []; + const expectedBulkOperationResults: Array< + Either< + { id: string; type: string; error: any }, + { + id: string; + type: string; + esRequestIndex: number; + doc: estypes.GetGetResult; + } + > + > = expectedBulkGetResults.map((expectedBulkGetResult) => { + if (isLeft(expectedBulkGetResult)) { + return expectedBulkGetResult; + } + + const { id, type, esRequestIndex } = expectedBulkGetResult.value; + const rawDoc = bulkGetResponse!.body.docs[esRequestIndex]; + + if (isMgetError(rawDoc) || !rawDoc?.found) { + const error = SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + return left({ id, type, error }); + } + + if (!rawDocExistsInNamespace(registry, rawDoc as unknown as SavedObjectsRawDoc, namespace)) { + const error = SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + return left({ id, type, error }); + } + + if ( + authorizationResult.status !== 'fully_authorized' && + !authorizationResult.typeMap.has(type) + ) { + const error = SavedObjectsErrorHelpers.decorateForbiddenError( + new Error( + `User is not authorized to ${ + actionType === 'changeOwnership' ? 'change ownership' : 'change access mode' + } of type "${type}".` + ) + ); + return left({ id, type, error }); + } + + const currentSource = rawDoc._source; + + const versionProperties = getExpectedVersionProperties( + undefined, + rawDoc as unknown as SavedObjectsRawDoc + ); + + const documentMetadata = { + _id: serializer.generateRawId(undefined, type, id), + _index: getIndexForType(type), + ...versionProperties, + }; + + let documentToSave; + if (actionType === 'changeOwnership') { + documentToSave = { + updated_at: time, + updated_by: currentUserProfileUid, + accessControl: { + ...(currentSource?.accessControl || { accessMode: 'default' }), + owner: newOwnerProfileUid, + }, + }; + } else { + documentToSave = { + updated_at: time, + updated_by: currentUserProfileUid, + accessControl: { + ...(currentSource?.accessControl || { owner: currentUserProfileUid }), + accessMode: accessMode ?? 'default', + }, + }; + } + + bulkOperationParams.push({ update: documentMetadata }, { doc: documentToSave }); + + return right({ + type, + id, + esRequestIndex: bulkOperationRequestIndexCounter++, + doc: rawDoc, + }); + }); + + const bulkOperationResponse = bulkOperationParams.length + ? await client.bulk({ + refresh: 'wait_for', + operations: bulkOperationParams, + require_alias: true, + }) + : undefined; + + return { + objects: expectedBulkOperationResults.map<{ id: string; type: string; error?: any }>( + (expectedResult) => { + if (isLeft(expectedResult)) { + return expectedResult.value; + } + + const { type, id, esRequestIndex } = expectedResult.value; + const response = bulkOperationResponse?.items[esRequestIndex] ?? {}; + + const rawResponse = Object.values( + response + )[0] as unknown as GetBulkOperationErrorRawResponse; + const error = getBulkOperationError(type, id, rawResponse); + if (error) { + return { id, type, error }; + } + + return { id, type }; + } + ), + }; +}; diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/index.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/index.ts index 045c101b406cf..a4154a79dec04 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/index.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/index.ts @@ -10,7 +10,6 @@ export { incrementCounterInternal } from './increment_counter_internal'; export { internalBulkResolve, - isBulkResolveError, type InternalBulkResolveParams, type InternalSavedObjectsBulkResolveResponse, } from './internal_bulk_resolve'; diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/internal_bulk_resolve.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/internal_bulk_resolve.ts index 7e85296a795b3..800754aae289a 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/internal_bulk_resolve.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/internal_bulk_resolve.ts @@ -10,12 +10,18 @@ import type { MgetResponseItem } from '@elastic/elasticsearch/lib/api/types'; import { isNotFoundFromUnsupportedServer } from '@kbn/core-elasticsearch-server-internal'; -import type { - SavedObjectsBulkResolveObject, - SavedObjectsResolveOptions, - SavedObjectsResolveResponse, - SavedObjectsIncrementCounterField, - SavedObjectsIncrementCounterOptions, +import type { Right } from '@kbn/core-saved-objects-api-server'; +import { + type SavedObjectsBulkResolveObject, + type SavedObjectsResolveOptions, + type SavedObjectsResolveResponse, + type SavedObjectsIncrementCounterField, + type SavedObjectsIncrementCounterOptions, + type Either, + isLeft, + left, + right, + isRight, } from '@kbn/core-saved-objects-api-server'; import { SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server'; import { @@ -41,12 +47,6 @@ import { getSavedObjectFromSource, normalizeNamespace, rawDocExistsInNamespace, - type Either, - type Right, - isLeft, - isRight, - left, - right, } from '../utils'; import type { ApiExecutionContext } from '../types'; import type { RepositoryEsClient } from '../../repository_es_client'; @@ -78,13 +78,6 @@ export interface InternalSavedObjectsBulkResolveResponse { resolved_objects: Array | BulkResolveError>; } -/** Type guard used in the repository. */ -export function isBulkResolveError( - result: SavedObjectsResolveResponse | BulkResolveError -): result is BulkResolveError { - return !!(result as BulkResolveError).error; -} - type AliasInfo = Pick; export async function internalBulkResolve( diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/preflight_check_for_create.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/preflight_check_for_create.ts index 98968236c855c..87b7434bababe 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/preflight_check_for_create.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/preflight_check_for_create.ts @@ -22,10 +22,11 @@ import { getObjectKey, type LegacyUrlAlias, } from '@kbn/core-saved-objects-base-server-internal'; +import { type Either, isLeft, isRight, left, right } from '@kbn/core-saved-objects-api-server'; import { findLegacyUrlAliases } from './find_legacy_url_aliases'; import type { CreatePointInTimeFinderFn } from '../../point_in_time_finder'; import type { RepositoryEsClient } from '../../repository_es_client'; -import { left, right, isLeft, isRight, rawDocExistsInNamespaces, type Either } from '../utils'; +import { rawDocExistsInNamespaces } from '../utils'; /** * If the object will be created in this many spaces (or "*" all current and future spaces), we use find to fetch all aliases. @@ -261,7 +262,7 @@ async function bulkGetObjectsAndAliases( docsToBulkGet.push({ _id: serializer.generateRawId(undefined, type, id), // namespace is intentionally undefined because multi-namespace objects don't have a namespace in their raw ID _index: getIndexForType(type), - _source: ['type', 'namespaces', 'originId'], + _source: ['type', 'namespaces', 'originId', 'accessControl'], }); if (checkAliases) { for (const space of spaces) { diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/repository_bulk_delete_internal_types.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/repository_bulk_delete_internal_types.ts index 5f723faeae9e3..a79156421f4b0 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/repository_bulk_delete_internal_types.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/repository_bulk_delete_internal_types.ts @@ -14,7 +14,8 @@ import type { ErrorCause, } from '@elastic/elasticsearch/lib/api/types'; import type { estypes, TransportResult } from '@elastic/elasticsearch'; -import type { Either } from '../utils'; +import type { SavedObjectAccessControl } from '@kbn/core-saved-objects-server'; +import type { Either } from '@kbn/core-saved-objects-api-server'; import type { DeleteLegacyUrlAliasesParams } from './delete_legacy_url_aliases'; /** @@ -49,12 +50,13 @@ export interface BulkDeleteParams { * @internal */ export type ExpectedBulkDeleteResult = Either< - { type: string; id: string; error: Payload }, + { type: string; id: string; error: Payload; accessControl?: SavedObjectAccessControl }, { type: string; id: string; namespaces: string[]; esRequestIndex: number; + accessControl?: SavedObjectAccessControl; } >; @@ -79,7 +81,14 @@ export type NewBulkItemResponse = BulkResponseItem & { error: ErrorCause & { ind */ export type BulkDeleteExpectedBulkGetResult = Either< { type: string; id: string; error: Payload }, - { type: string; id: string; version?: string; esRequestIndex?: number; fields?: string[] } + { + type: string; + id: string; + version?: string; + esRequestIndex?: number; + fields?: string[]; + accessControl?: SavedObjectAccessControl; + } >; export type ObjectToDeleteAliasesFor = Pick< diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/update_objects_spaces.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/update_objects_spaces.ts index f85e7bb3cdf57..1750b736f14ce 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/update_objects_spaces.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/internals/update_objects_spaces.ts @@ -13,11 +13,16 @@ import { intersection } from 'lodash'; import type { Logger } from '@kbn/logging'; import { isNotFoundFromUnsupportedServer } from '@kbn/core-elasticsearch-server-internal'; -import type { - SavedObjectsUpdateObjectsSpacesObject, - SavedObjectsUpdateObjectsSpacesOptions, - SavedObjectsUpdateObjectsSpacesResponse, - SavedObjectsUpdateObjectsSpacesResponseObject, +import { + isLeft, + isRight, + left, + right, + type Either, + type SavedObjectsUpdateObjectsSpacesObject, + type SavedObjectsUpdateObjectsSpacesOptions, + type SavedObjectsUpdateObjectsSpacesResponse, + type SavedObjectsUpdateObjectsSpacesResponseObject, } from '@kbn/core-saved-objects-api-server'; import type { SavedObject, @@ -34,11 +39,6 @@ import { getBulkOperationError, getExpectedVersionProperties, rawDocExistsInNamespace, - type Either, - isLeft, - isRight, - left, - right, } from '../utils'; import { DEFAULT_REFRESH_SETTING } from '../../constants'; import type { RepositoryEsClient } from '../../repository_es_client'; diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/resolve.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/resolve.ts index f0de5066675b1..7e95fa40aa34c 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/resolve.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/resolve.ts @@ -11,8 +11,9 @@ import type { SavedObjectsResolveOptions, SavedObjectsResolveResponse, } from '@kbn/core-saved-objects-api-server'; +import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; import type { ApiExecutionContext } from './types'; -import { internalBulkResolve, isBulkResolveError } from './internals/internal_bulk_resolve'; +import { internalBulkResolve } from './internals/internal_bulk_resolve'; import { incrementCounterInternal } from './internals/increment_counter_internal'; export interface PerformCreateParams { @@ -41,7 +42,7 @@ export const performResolve = async ( ); const [result] = bulkResults; - if (isBulkResolveError(result)) { + if (SavedObjectsErrorHelpers.isBulkResolveError(result)) { throw result.error; } return result; diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/update.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/update.ts index e6e534d745711..8874e042e7493 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/update.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/update.ts @@ -110,12 +110,16 @@ export const executeUpdate = async ( }); const existingNamespaces = preflightDocNSResult.savedObjectNamespaces ?? []; + const accessControl = preflightDocNSResult.rawDocSource?._source.accessControl; const authorizationResult = await securityExtension?.authorizeUpdate({ namespace, object: { type, id, existingNamespaces, + ...(accessControl && { + accessControl, + }), objectNamespace: namespace && registry.isSingleNamespace(type) ? namespace : undefined, name: SavedObjectsUtils.getName(registry.getNameAttribute(type), { attributes: { ...(preflightDocResult.rawDocSource?._source?.[type] ?? {}), ...attributes }, @@ -178,6 +182,13 @@ export const executeUpdate = async ( // UPSERT CASE START if (shouldPerformUpsert) { + // Note: Upsert does not support adding accessControl properties. To do so, the `create` API should be used. + + if (registry.supportsAccessControl(type)) { + throw SavedObjectsErrorHelpers.createBadRequestError( + `"update" does not support "upsert" of objects that support access control. Use "create" or "bulk create" instead.` + ); + } // ignore attributes if creating a new doc: only use the upsert attributes // don't include upsert if the object already exists; ES doesn't allow upsert in combination with version properties const migratedUpsert = migrationHelper.migrateInputDocument({ @@ -290,6 +301,7 @@ export const executeUpdate = async ( attributes: updatedAttributes, updated_at: time, updated_by: updatedBy, + ...(accessControl ? { accessControl } : {}), ...(Array.isArray(references) && { references }), }); diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/utils/index.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/utils/index.ts index e51be5dd2cfd3..084404838f780 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/utils/index.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/utils/index.ts @@ -11,7 +11,6 @@ export { isFoundGetResponse, type GetResponseFound } from './es_responses'; export { findSharedOriginObjects } from './find_shared_origin_objects'; export { rawDocExistsInNamespace, - errorContent, rawDocExistsInNamespaces, isMgetDoc, getCurrentTime, @@ -23,5 +22,4 @@ export { getSavedObjectNamespaces, type GetSavedObjectFromSourceOptions, } from './internal_utils'; -export { type Left, type Either, type Right, isLeft, isRight, left, right } from './either'; export { mergeForUpdate } from './merge_for_update'; diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/utils/internal_utils.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/utils/internal_utils.ts index 80fd9705fceab..48e9721316417 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/utils/internal_utils.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/apis/utils/internal_utils.ts @@ -16,7 +16,7 @@ import { type SavedObjectsRawDocSource, type SavedObject, type SavedObjectsRawDocParseOptions, - type DecoratedError, + type SavedObjectAccessControl, } from '@kbn/core-saved-objects-server'; import { SavedObjectsUtils, ALL_NAMESPACES_STRING } from '@kbn/core-saved-objects-utils-server'; import { @@ -24,6 +24,12 @@ import { encodeHitVersion, } from '@kbn/core-saved-objects-base-server-internal'; +export interface GetBulkOperationErrorRawResponse { + status: number; + error: { type: string; reason?: string | null; index: string }; + // Other fields are present on a bulk operation result but they are irrelevant for this function +} + /** * Checks the raw response of a bulk operation and returns an error if necessary. * @@ -36,11 +42,7 @@ import { export function getBulkOperationError( type: string, id: string, - rawResponse: { - status: number; - error?: { type: string; reason?: string | null; index: string }; - // Other fields are present on a bulk operation result but they are irrelevant for this function - } + rawResponse: GetBulkOperationErrorRawResponse ): Payload | undefined { const { status, error } = rawResponse; if (error) { @@ -115,6 +117,7 @@ export function getSavedObjectFromSource( coreMigrationVersion, typeMigrationVersion, managed, + accessControl, migrationVersion = migrationVersionCompatibility === 'compatible' && typeMigrationVersion ? { [type]: typeMigrationVersion } : undefined, @@ -143,6 +146,7 @@ export function getSavedObjectFromSource( attributes: doc._source[type], references: doc._source.references || [], managed, + accessControl, }; } @@ -286,11 +290,29 @@ export function getSavedObjectNamespaces( return [SavedObjectsUtils.namespaceIdToString(namespace)]; } -/** - * Extracts the contents of a decorated error to return the attributes for bulk operations. - */ -export const errorContent = (error: DecoratedError) => error.output.payload; - export function isMgetDoc(doc?: estypes.MgetResponseItem): doc is estypes.GetGetResult { return Boolean(doc && 'found' in doc); } + +export function isMgetError( + doc?: estypes.MgetResponseItem +): doc is estypes.MgetMultiGetError { + return Boolean(doc && 'error' in doc); +} + +export function setAccessControl({ + typeSupportsAccessControl, + createdBy, + accessMode, +}: { + typeSupportsAccessControl: boolean; + createdBy?: string; + accessMode?: SavedObjectAccessControl['accessMode']; +}) { + return typeSupportsAccessControl && createdBy + ? { + owner: createdBy, + accessMode: accessMode ?? 'default', + } + : undefined; +} diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/repository.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/repository.ts index 1af1039a2a256..af5c137d0a78e 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/lib/repository.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/repository.ts @@ -55,6 +55,10 @@ import type { SavedObjectsSearchOptions, SavedObjectsSearchResponse, ISavedObjectsRepository, + SavedObjectsChangeAccessControlResponse, + SavedObjectsChangeAccessControlObject, + SavedObjectsChangeAccessModeOptions, + SavedObjectsChangeOwnershipOptions, } from '@kbn/core-saved-objects-api-server'; import type { ISavedObjectTypeRegistry, @@ -65,6 +69,7 @@ import { SavedObjectsSerializer, type IndexMapping, type IKibanaMigrator, + type ISavedObjectTypeRegistryInternal, } from '@kbn/core-saved-objects-base-server-internal'; import { PointInTimeFinder } from './point_in_time_finder'; import { createRepositoryEsClient, type RepositoryEsClient } from './repository_es_client'; @@ -92,6 +97,8 @@ import { performSearch, } from './apis'; import { createRepositoryHelpers } from './utils'; +import { performChangeOwnership } from './apis/change_ownership'; +import { performChangeAccessMode } from './apis/change_access_mode'; /** * Constructor options for {@link SavedObjectsRepository} @@ -138,7 +145,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { */ public static createRepository( migrator: IKibanaMigrator, - typeRegistry: ISavedObjectTypeRegistry, + typeRegistry: ISavedObjectTypeRegistryInternal, indexName: string, client: ElasticsearchClient, logger: Logger, @@ -590,4 +597,24 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { }, }); } + + /** + * {@inheritDoc ISavedObjectsRepository.changeOwnership} + */ + async changeOwnership( + objects: SavedObjectsChangeAccessControlObject[], + options: SavedObjectsChangeOwnershipOptions + ): Promise { + return await performChangeOwnership({ objects, options }, this.apiExecutionContext); + } + + /** + * {@inheritDoc ISavedObjectsRepository.changeAccessMode} + */ + async changeAccessMode( + objects: SavedObjectsChangeAccessControlObject[], + options: SavedObjectsChangeAccessModeOptions + ): Promise { + return await performChangeAccessMode({ objects, options }, this.apiExecutionContext); + } } diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/utils/included_fields.test.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/utils/included_fields.test.ts index 696404d470e6c..b3ce638885aaf 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/lib/utils/included_fields.test.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/utils/included_fields.test.ts @@ -22,6 +22,7 @@ describe('getRootFields', () => { "coreMigrationVersion", "typeMigrationVersion", "managed", + "accessControl", "updated_at", "updated_by", "created_at", diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/utils/included_fields.ts b/src/core/packages/saved-objects/api-server-internal/src/lib/utils/included_fields.ts index 333268fb42965..0043cb9522b68 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/lib/utils/included_fields.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/lib/utils/included_fields.ts @@ -16,6 +16,7 @@ const ROOT_FIELDS = [ 'coreMigrationVersion', 'typeMigrationVersion', 'managed', + 'accessControl', 'updated_at', 'updated_by', 'created_at', diff --git a/src/core/packages/saved-objects/api-server-internal/src/mocks/repository.mock.ts b/src/core/packages/saved-objects/api-server-internal/src/mocks/repository.mock.ts index 8800914c8c4d0..c0f59e3e46c17 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/mocks/repository.mock.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/mocks/repository.mock.ts @@ -36,6 +36,8 @@ const createRepositoryMock = () => { updateObjectsSpaces: jest.fn(), getCurrentNamespace: jest.fn(), asScopedToNamespace: jest.fn().mockImplementation(createRepositoryMock), + changeOwnership: jest.fn(), + changeAccessMode: jest.fn(), }; return mock; diff --git a/src/core/packages/saved-objects/api-server-internal/src/mocks/saved_objects_extensions.mock.ts b/src/core/packages/saved-objects/api-server-internal/src/mocks/saved_objects_extensions.mock.ts index 3514927af9a85..3dd4b4db71a82 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/mocks/saved_objects_extensions.mock.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/mocks/saved_objects_extensions.mock.ts @@ -12,6 +12,8 @@ import type { ISavedObjectsSecurityExtension, ISavedObjectsSpacesExtension, } from '@kbn/core-saved-objects-server'; +import type { Either } from '@kbn/core-saved-objects-api-server'; +import type { Payload } from '@hapi/boom'; const createEncryptionExtension = (): jest.Mocked => ({ isEncryptableType: jest.fn(), @@ -43,6 +45,19 @@ const createSecurityExtension = (): jest.Mocked auditObjectsForSpaceDeletion: jest.fn(), getCurrentUser: jest.fn(), includeSavedObjectNames: jest.fn(), + authorizeChangeAccessControl: jest.fn(), + filterInaccessibleObjectsForBulkAction: jest + .fn() + .mockImplementation( + ( + expectedResults: Either< + { type: string; id?: string | undefined; error: Payload }, + { type: string; id: string; esRequestIndex?: number | undefined } + >[] + ) => { + return Promise.resolve(expectedResults); + } + ), }); const createSpacesExtension = (): jest.Mocked => ({ diff --git a/src/core/packages/saved-objects/api-server-internal/src/saved_objects_client.ts b/src/core/packages/saved-objects/api-server-internal/src/saved_objects_client.ts index fcce2bbd46b59..f340dc6203349 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/saved_objects_client.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/saved_objects_client.ts @@ -45,6 +45,10 @@ import type { SavedObjectsBulkDeleteObject, SavedObjectsBulkDeleteOptions, SavedObjectsBulkDeleteResponse, + SavedObjectsChangeAccessControlResponse, + SavedObjectsChangeAccessControlObject, + SavedObjectsChangeAccessModeOptions, + SavedObjectsChangeOwnershipOptions, SavedObjectsRawDocSource, SavedObjectsSearchOptions, SavedObjectsSearchResponse, @@ -231,4 +235,20 @@ export class SavedObjectsClient implements SavedObjectsClientContract { asScopedToNamespace(namespace: string) { return new SavedObjectsClient(this._repository.asScopedToNamespace(namespace)); } + + /** {@inheritDoc SavedObjectsClientContract.changeOwnership} */ + changeOwnership( + objects: SavedObjectsChangeAccessControlObject[], + options: SavedObjectsChangeOwnershipOptions + ): Promise { + return this._repository.changeOwnership(objects, options); + } + + /** {@inheritDoc SavedObjectsClientContract.changeAccessMode} */ + changeAccessMode( + objects: SavedObjectsChangeAccessControlObject[], + options: SavedObjectsChangeAccessModeOptions + ): Promise { + return this._repository.changeAccessMode(objects, options); + } } diff --git a/src/core/packages/saved-objects/api-server-internal/src/test_helpers/repository.test.common.ts b/src/core/packages/saved-objects/api-server-internal/src/test_helpers/repository.test.common.ts index db66adddfe9c1..0e48dd4d469a4 100644 --- a/src/core/packages/saved-objects/api-server-internal/src/test_helpers/repository.test.common.ts +++ b/src/core/packages/saved-objects/api-server-internal/src/test_helpers/repository.test.common.ts @@ -54,6 +54,7 @@ import { type AuthorizationTypeMap, SavedObjectsErrorHelpers, } from '@kbn/core-saved-objects-server'; +import type { ISavedObjectTypeRegistryInternal } from '@kbn/core-saved-objects-base-server-internal'; import { mockGetSearchDsl } from '../lib/repository.test.mock'; import type { SavedObjectsRepository } from '../lib/repository'; @@ -141,6 +142,7 @@ export const mockTimestampFieldsWithCreated = { updated_at: mockTimestamp, created_at: mockTimestamp, }; +export const ACCESS_CONTROL_TYPE = 'accessControlType'; export const REMOVE_REFS_COUNT = 42; export interface TypeIdTuple { @@ -227,6 +229,13 @@ export const mappings: SavedObjectsTypeMappingDefinition = { }, }, }, + [ACCESS_CONTROL_TYPE]: { + properties: { + accessControl: { + type: 'object', + }, + }, + }, }, }; @@ -384,10 +393,16 @@ export const createRegistry = () => { namespaceType: 'multiple', }) ); + registry.registerType( + createType(ACCESS_CONTROL_TYPE, { + supportsAccessControl: true, + namespaceType: 'multiple-isolated', + }) + ); return registry; }; -export const createSpySerializer = (registry: SavedObjectTypeRegistry) => { +export const createSpySerializer = (registry: ISavedObjectTypeRegistryInternal) => { const serializer = new SavedObjectsSerializer(registry); for (const method of [ @@ -827,13 +842,13 @@ export const deleteSuccess = async ( if (registry.isMultiNamespace(type)) { const mockGetResponse = mockGetResponseValue ?? getMockGetResponse(registry, { type, id }, options?.namespace); - client.get.mockResponseOnce(mockGetResponse); + client.get.mockResponse(mockGetResponse); } client.delete.mockResponseOnce({ result: 'deleted', } as estypes.DeleteResponse); const result = await repository.delete(type, id, options); - expect(client.get).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 1 : 0); + client.get.mockClear(); return result; }; diff --git a/src/core/packages/saved-objects/api-server-mocks/src/repository.mock.ts b/src/core/packages/saved-objects/api-server-mocks/src/repository.mock.ts index a2f9fc20db3d9..5ccc3fc927fff 100644 --- a/src/core/packages/saved-objects/api-server-mocks/src/repository.mock.ts +++ b/src/core/packages/saved-objects/api-server-mocks/src/repository.mock.ts @@ -36,6 +36,8 @@ const create = () => { updateObjectsSpaces: jest.fn(), getCurrentNamespace: jest.fn(), asScopedToNamespace: jest.fn().mockImplementation(create), + changeOwnership: jest.fn(), + changeAccessMode: jest.fn(), }); mock.createPointInTimeFinder = savedObjectsPointInTimeFinderMock.create({ diff --git a/src/core/packages/saved-objects/api-server-mocks/src/saved_objects_client.mock.ts b/src/core/packages/saved-objects/api-server-mocks/src/saved_objects_client.mock.ts index c556bb34c701e..56271cb7f4278 100644 --- a/src/core/packages/saved-objects/api-server-mocks/src/saved_objects_client.mock.ts +++ b/src/core/packages/saved-objects/api-server-mocks/src/saved_objects_client.mock.ts @@ -34,6 +34,8 @@ const create = () => { updateObjectsSpaces: jest.fn(), getCurrentNamespace: jest.fn(), asScopedToNamespace: jest.fn().mockImplementation(create), + changeOwnership: jest.fn(), + changeAccessMode: jest.fn(), }); mock.createPointInTimeFinder = savedObjectsPointInTimeFinderMock.create({ diff --git a/src/core/packages/saved-objects/api-server-mocks/src/saved_objects_extensions.mock.ts b/src/core/packages/saved-objects/api-server-mocks/src/saved_objects_extensions.mock.ts index ca6ab9696c331..47fc812b613d2 100644 --- a/src/core/packages/saved-objects/api-server-mocks/src/saved_objects_extensions.mock.ts +++ b/src/core/packages/saved-objects/api-server-mocks/src/saved_objects_extensions.mock.ts @@ -47,6 +47,8 @@ const createSecurityExtension = (): jest.Mocked auditObjectsForSpaceDeletion: jest.fn(), getCurrentUser: jest.fn(), includeSavedObjectNames: jest.fn(), + authorizeChangeAccessControl: jest.fn(), + filterInaccessibleObjectsForBulkAction: jest.fn(), }); const createSpacesExtension = (): jest.Mocked => diff --git a/src/core/packages/saved-objects/api-server/index.ts b/src/core/packages/saved-objects/api-server/index.ts index 7722e412b7165..b1f0b3e2434f8 100644 --- a/src/core/packages/saved-objects/api-server/index.ts +++ b/src/core/packages/saved-objects/api-server/index.ts @@ -62,12 +62,20 @@ export type { SavedObjectsBulkDeleteOptions, SavedObjectsBulkDeleteStatus, SavedObjectsBulkDeleteResponse, + SavedObjectsChangeAccessControlOptions, + SavedObjectsChangeAccessControlResponse, + SavedObjectsChangeAccessControlObject, + SavedObjectsChangeOwnershipOptions, + SavedObjectsChangeAccessModeOptions, SavedObjectsSearchOptions, SavedObjectsSearchResponse, } from './src/apis'; +export { type Left, type Either, type Right, isLeft, isRight, left, right } from './src/utils'; + export type { SavedObject, + SavedObjectAccessControl, SavedObjectAttribute, SavedObjectAttributes, SavedObjectAttributeSingle, diff --git a/src/core/packages/saved-objects/api-server/src/apis/bulk_create.ts b/src/core/packages/saved-objects/api-server/src/apis/bulk_create.ts index 7d00f415675f1..f725ca5c8c751 100644 --- a/src/core/packages/saved-objects/api-server/src/apis/bulk_create.ts +++ b/src/core/packages/saved-objects/api-server/src/apis/bulk_create.ts @@ -8,7 +8,7 @@ */ import type { SavedObjectsMigrationVersion } from '@kbn/core-saved-objects-common'; -import type { SavedObjectReference } from '../..'; +import type { SavedObjectAccessControl, SavedObjectReference } from '../..'; /** * Object parameters for the bulk create operation @@ -64,4 +64,12 @@ export interface SavedObjectsBulkCreateObject { * make their edits to the copy. */ managed?: boolean; + + /** + * The access control settings for the object + * + * We specifically exclude the owner property, as that is set during the operation + * using the current user's profile ID. + */ + accessControl?: Pick; } diff --git a/src/core/packages/saved-objects/api-server/src/apis/change_access_control.ts b/src/core/packages/saved-objects/api-server/src/apis/change_access_control.ts new file mode 100644 index 0000000000000..3295bbe0d513b --- /dev/null +++ b/src/core/packages/saved-objects/api-server/src/apis/change_access_control.ts @@ -0,0 +1,49 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ +import type { SavedObjectError } from '@kbn/core-saved-objects-common'; + +import type { SavedObjectAccessControl } from '../..'; +import type { SavedObjectsBaseOptions } from './base'; + +export interface SavedObjectsChangeAccessControlObject { + type: string; + id: string; +} + +export interface SavedObjectsChangeOwnershipOptions extends SavedObjectsBaseOptions { + newOwnerProfileUid?: SavedObjectAccessControl['owner']; +} + +export interface SavedObjectsChangeAccessModeOptions extends SavedObjectsBaseOptions { + accessMode?: SavedObjectAccessControl['accessMode']; +} + +/** + * Options for the changing ownership of a saved object + * + * @public + */ +export type SavedObjectsChangeAccessControlOptions = + | SavedObjectsChangeOwnershipOptions + | SavedObjectsChangeAccessModeOptions; + +/** + * Return type of the Saved Objects `changeOwnership()` method. + * + * @public + */ +export interface SavedObjectsChangeAccessControlResponse { + objects: SavedObjectsChangeAccessControlResponseObject[]; +} + +export interface SavedObjectsChangeAccessControlResponseObject { + id: string; + type: string; + error?: SavedObjectError; +} diff --git a/src/core/packages/saved-objects/api-server/src/apis/create.ts b/src/core/packages/saved-objects/api-server/src/apis/create.ts index f0939062dae75..d573e3d277d4b 100644 --- a/src/core/packages/saved-objects/api-server/src/apis/create.ts +++ b/src/core/packages/saved-objects/api-server/src/apis/create.ts @@ -8,7 +8,7 @@ */ import type { SavedObjectsMigrationVersion } from '@kbn/core-saved-objects-common'; -import type { SavedObjectReference } from '../..'; +import type { SavedObjectAccessControl, SavedObjectReference } from '../..'; import type { MutatingOperationRefreshSetting, SavedObjectsBaseOptions } from './base'; /** @@ -72,4 +72,13 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { managed?: boolean; /** {@link SavedObjectsRawDocParseOptions.migrationVersionCompatibility} */ migrationVersionCompatibility?: 'compatible' | 'raw'; + + /** + * Access control settings for the create operation. + * + * These settings will be applied to any of the incoming objects which support access control that + * do not already contain the accessControl property. We specifically exclude the owner property, + * as that is set during the operation using the current user's profile ID. + */ + accessControl?: Pick; } diff --git a/src/core/packages/saved-objects/api-server/src/apis/index.ts b/src/core/packages/saved-objects/api-server/src/apis/index.ts index 6abd3ac305b3b..1caad72174036 100644 --- a/src/core/packages/saved-objects/api-server/src/apis/index.ts +++ b/src/core/packages/saved-objects/api-server/src/apis/index.ts @@ -81,4 +81,12 @@ export type { SavedObjectsBulkDeleteStatus, SavedObjectsBulkDeleteResponse, } from './bulk_delete'; +export type { + SavedObjectsChangeAccessControlOptions, + SavedObjectsChangeAccessControlResponse, + SavedObjectsChangeAccessControlResponseObject, + SavedObjectsChangeAccessControlObject, + SavedObjectsChangeOwnershipOptions, + SavedObjectsChangeAccessModeOptions, +} from './change_access_control'; export type { SavedObjectsSearchOptions, SavedObjectsSearchResponse } from './search'; diff --git a/src/core/packages/saved-objects/api-server/src/saved_objects_client.ts b/src/core/packages/saved-objects/api-server/src/saved_objects_client.ts index af2d6d97bfa5f..0d7509162cc4d 100644 --- a/src/core/packages/saved-objects/api-server/src/saved_objects_client.ts +++ b/src/core/packages/saved-objects/api-server/src/saved_objects_client.ts @@ -47,6 +47,10 @@ import type { SavedObjectsBulkDeleteObject, SavedObjectsBulkDeleteOptions, SavedObjectsBulkDeleteResponse, + SavedObjectsChangeAccessControlResponse, + SavedObjectsChangeAccessControlObject, + SavedObjectsChangeAccessModeOptions, + SavedObjectsChangeOwnershipOptions, SavedObjectsSearchOptions, SavedObjectsSearchResponse, } from './apis'; @@ -455,4 +459,27 @@ export interface SavedObjectsClientContract { * @param namespace Space to which the client should be scoped to. */ asScopedToNamespace(namespace: string): SavedObjectsClientContract; + + /** + * Changes the ownership of one or more SavedObjects to a new owner passed in the options. + * + * @param objects - The objects to change ownership for + * @param options {@link SavedObjectsChangeAccessControlOptions} - options for the change ownership operation + * @returns the {@link SavedObjectsChangeAccessControlResponse} + */ + changeOwnership( + objects: SavedObjectsChangeAccessControlObject[], + options: SavedObjectsChangeOwnershipOptions + ): Promise; + + /** + * Changes the access mode of one or more SavedObjects. + * @param objects - The objects to change access mode for + * @param options {@link SavedObjectsChangeAccessModeOptions} - options for the change access mode operation + * @returns the {@link SavedObjectsChangeAccessControlResponse} + */ + changeAccessMode( + objects: SavedObjectsChangeAccessControlObject[], + options: SavedObjectsChangeAccessModeOptions + ): Promise; } diff --git a/src/core/packages/saved-objects/api-server/src/saved_objects_repository.ts b/src/core/packages/saved-objects/api-server/src/saved_objects_repository.ts index 833fc06a834b6..5be66d8abe2eb 100644 --- a/src/core/packages/saved-objects/api-server/src/saved_objects_repository.ts +++ b/src/core/packages/saved-objects/api-server/src/saved_objects_repository.ts @@ -51,6 +51,10 @@ import type { SavedObjectsBulkDeleteOptions, SavedObjectsBulkDeleteObject, SavedObjectsBulkDeleteResponse, + SavedObjectsChangeOwnershipOptions, + SavedObjectsChangeAccessModeOptions, + SavedObjectsChangeAccessControlResponse, + SavedObjectsChangeAccessControlObject, SavedObjectsSearchOptions, SavedObjectsSearchResponse, } from './apis'; @@ -573,4 +577,27 @@ export interface ISavedObjectsRepository { * @param namespace Space to which the repository should be scoped to. */ asScopedToNamespace(namespace: string): ISavedObjectsRepository; + + /** + * Changes the ownership of one or more SavedObjects to a new owner. + * + * @param objects {@link SavedObjectsChangeAccessControlObject} - the objects to update + * @param options {@link SavedObjectsChangeOwnershipOptions} - object containing owner profile_uid that will be the new owner + * @returns the {@link SavedObjectsChangeAccessControlResponse} + */ + changeOwnership( + objects: SavedObjectsChangeAccessControlObject[], + options: SavedObjectsChangeOwnershipOptions + ): Promise; + + /** + * Changes the access mode of one or more SavedObjects to a new access mode. + * + * @param objects {@link SavedObjectsChangeAccessControlObject} - the objects to update + * @param options {@link SavedObjectsChangeAccessControlOptions} - object containing access mode. If empty, is considered to be marked as editable + */ + changeAccessMode( + objects: SavedObjectsChangeAccessControlObject[], + options: SavedObjectsChangeAccessModeOptions + ): Promise; } diff --git a/src/core/packages/saved-objects/api-server-internal/src/lib/apis/utils/either.ts b/src/core/packages/saved-objects/api-server/src/utils/either.ts similarity index 100% rename from src/core/packages/saved-objects/api-server-internal/src/lib/apis/utils/either.ts rename to src/core/packages/saved-objects/api-server/src/utils/either.ts diff --git a/src/core/packages/saved-objects/api-server/src/utils/index.ts b/src/core/packages/saved-objects/api-server/src/utils/index.ts new file mode 100644 index 0000000000000..3f15bba808686 --- /dev/null +++ b/src/core/packages/saved-objects/api-server/src/utils/index.ts @@ -0,0 +1,10 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { type Left, type Either, type Right, isLeft, isRight, left, right } from './either'; diff --git a/src/core/packages/saved-objects/base-server-internal/index.ts b/src/core/packages/saved-objects/base-server-internal/index.ts index 7d36899af08ba..f878560d00c11 100644 --- a/src/core/packages/saved-objects/base-server-internal/index.ts +++ b/src/core/packages/saved-objects/base-server-internal/index.ts @@ -36,6 +36,7 @@ export { type SavedObjectsConfigType, type SavedObjectsMigrationConfigType, } from './src/saved_objects_config'; +export type { ISavedObjectTypeRegistryInternal } from './src/saved_objects_type_registry'; export { SavedObjectTypeRegistry } from './src/saved_objects_type_registry'; export type { IKibanaMigrator, diff --git a/src/core/packages/saved-objects/base-server-internal/src/saved_objects_config.ts b/src/core/packages/saved-objects/base-server-internal/src/saved_objects_config.ts index 03b59a15d69d3..a6bc9635775e3 100644 --- a/src/core/packages/saved-objects/base-server-internal/src/saved_objects_config.ts +++ b/src/core/packages/saved-objects/base-server-internal/src/saved_objects_config.ts @@ -82,6 +82,7 @@ const soSchema = schema.object({ schema.boolean({ defaultValue: true }), schema.boolean({ defaultValue: false }) ), + enableAccessControl: schema.boolean({ defaultValue: false }), }); export type SavedObjectsConfigType = TypeOf; @@ -97,6 +98,7 @@ export class SavedObjectConfig { /* @internal depend on env: see https://github.com/elastic/dev/issues/2200 */ public allowHttpApiAccess: boolean; public migration: SavedObjectsMigrationConfigType; + public enableAccessControl: boolean; constructor( rawConfig: SavedObjectsConfigType, @@ -106,5 +108,6 @@ export class SavedObjectConfig { this.maxImportExportSize = rawConfig.maxImportExportSize; this.migration = rawMigrationConfig; this.allowHttpApiAccess = rawConfig.allowHttpApiAccess; + this.enableAccessControl = rawConfig.enableAccessControl; } } diff --git a/src/core/packages/saved-objects/base-server-internal/src/saved_objects_type_registry.test.ts b/src/core/packages/saved-objects/base-server-internal/src/saved_objects_type_registry.test.ts index 0c4382f39192a..54fbf8df9bf98 100644 --- a/src/core/packages/saved-objects/base-server-internal/src/saved_objects_type_registry.test.ts +++ b/src/core/packages/saved-objects/base-server-internal/src/saved_objects_type_registry.test.ts @@ -213,6 +213,86 @@ describe('SavedObjectTypeRegistry', () => { ); }).not.toThrow(); }); + + describe(`supports access control`, () => { + it('sets `supportsAccessControl` when access control feature is enabled', () => { + registry.setAccessControlEnabled(true); + + expect(() => { + registry.registerType( + createType({ + name: 'typeAC', + namespaceType: 'multiple', + supportsAccessControl: true, + }) + ); + }).not.toThrow(); + + expect(() => { + registry.registerType( + createType({ + name: 'typeNAC', + namespaceType: 'multiple', + supportsAccessControl: false, + }) + ); + }).not.toThrow(); + + let readback = registry.getType('typeAC'); + expect(readback).toBeDefined(); + expect(readback?.supportsAccessControl).toBe(true); + + readback = registry.getType('typeNAC'); + expect(readback).toBeDefined(); + expect(readback?.supportsAccessControl).toBe(false); + }); + + it('throws when `supportsAccessControl` is true and namespaceType is not multiple or multiple-isolated', () => { + expect(() => { + registry.registerType( + createType({ + name: 'typeAC', + supportsAccessControl: true, + namespaceType: 'agnostic', + }) + ); + }).toThrowErrorMatchingInlineSnapshot( + `"Type typeAC: Cannot specify 'supportsAccessControl' as 'true' unless 'namespaceType' is either 'multiple' or 'multiple-isolated'."` + ); + }); + + it('overwrites `supportsAccessControl` to false when access control feature is disabled', () => { + registry.setAccessControlEnabled(false); + + expect(() => { + registry.registerType( + createType({ + name: 'typeAC', + namespaceType: 'multiple', + supportsAccessControl: true, + }) + ); + }).not.toThrow(); + + expect(() => { + registry.registerType( + createType({ + name: 'typeNAC', + supportsAccessControl: false, + }) + ); + }).not.toThrow(); + + let readback = registry.getType('typeAC'); + expect(readback).toBeDefined(); + expect(readback?.supportsAccessControl).toBe(false); + + readback = registry.getType('typeNAC'); + expect(readback).toBeDefined(); + expect(readback?.supportsAccessControl).toBe(false); + }); + }); + // TODO: same test with 'onImport' }); diff --git a/src/core/packages/saved-objects/base-server-internal/src/saved_objects_type_registry.ts b/src/core/packages/saved-objects/base-server-internal/src/saved_objects_type_registry.ts index f0770e36fb965..fddf2b43c12ed 100644 --- a/src/core/packages/saved-objects/base-server-internal/src/saved_objects_type_registry.ts +++ b/src/core/packages/saved-objects/base-server-internal/src/saved_objects_type_registry.ts @@ -14,25 +14,45 @@ export interface SavedObjectTypeRegistryConfig { legacyTypes?: string[]; } +export interface ISavedObjectTypeRegistryInternal extends ISavedObjectTypeRegistry { + /** + * Register a {@link SavedObjectsType | type} inside the registry. + * A type can only be registered once. subsequent calls with the same type name will throw an error. + * + * @internal + */ + registerType(type: SavedObjectsType): void; + /** + * Sets whether access control is enabled + * + * @internal + */ + setAccessControlEnabled(enabled: boolean): void; + /** + * Gets whether access control is enabled + * + * @internal + */ + isAccessControlEnabled(): boolean; +} + /** * Core internal implementation of {@link ISavedObjectTypeRegistry}. * * @internal should only be used outside of Core for testing purposes. */ -export class SavedObjectTypeRegistry implements ISavedObjectTypeRegistry { +export class SavedObjectTypeRegistry implements ISavedObjectTypeRegistryInternal { private readonly types = new Map(); private readonly legacyTypesMap: Set; + private accessControlEnabled: boolean = true; + constructor({ legacyTypes = [] }: SavedObjectTypeRegistryConfig = {}) { this.legacyTypesMap = new Set(legacyTypes); } - /** - * Register a {@link SavedObjectsType | type} inside the registry. - * A type can only be registered once. subsequent calls with the same type name will throw an error. - * - * @internal - */ + /** {@inheritDoc ISavedObjectTypeRegistryInternal.registerType} */ + public registerType(type: SavedObjectsType) { if (this.types.has(type.name)) { throw new Error(`Type '${type.name}' is already registered`); @@ -42,11 +62,26 @@ export class SavedObjectTypeRegistry implements ISavedObjectTypeRegistry { `Type '${type.name}' can't be used because it's been added to the legacy types` ); } + + if ( + type.supportsAccessControl && + type.namespaceType !== 'multiple' && + type.namespaceType !== 'multiple-isolated' + ) { + throw new Error( + `Type ${type.name}: Cannot specify 'supportsAccessControl' as 'true' unless 'namespaceType' is either 'multiple' or 'multiple-isolated'.` + ); + } + const supportsAccessControl = this.accessControlEnabled ? type.supportsAccessControl : false; + + const typeWithAccessControl = { ...type, supportsAccessControl }; + validateType(type); + if (process.env.NODE_ENV !== 'production') { - deepFreeze(type); + deepFreeze(typeWithAccessControl); } - this.types.set(type.name, type); + this.types.set(type.name, typeWithAccessControl); } /** {@inheritDoc ISavedObjectTypeRegistry.getLegacyTypes} */ @@ -124,6 +159,20 @@ export class SavedObjectTypeRegistry implements ISavedObjectTypeRegistry { public getNameAttribute(type: string) { return this.types.get(type)?.nameAttribute || 'unknown'; } + + public supportsAccessControl(type: string): boolean { + return this.types.get(type)?.supportsAccessControl ?? false; + } + + /** {@inheritDoc ISavedObjectTypeRegistryInternal.setAccessControlEnabled} */ + public setAccessControlEnabled(enabled: boolean) { + this.accessControlEnabled = enabled; + } + + /** {@inheritDoc ISavedObjectTypeRegistryInternal.isAccessControlEnabled} */ + public isAccessControlEnabled() { + return this.accessControlEnabled; + } } const validateType = ({ name, management, hidden, hiddenFromHttpApis }: SavedObjectsType) => { diff --git a/src/core/packages/saved-objects/base-server-internal/src/serialization/serializer.test.ts b/src/core/packages/saved-objects/base-server-internal/src/serialization/serializer.test.ts index 6170c02acfa81..76da659027971 100644 --- a/src/core/packages/saved-objects/base-server-internal/src/serialization/serializer.test.ts +++ b/src/core/packages/saved-objects/base-server-internal/src/serialization/serializer.test.ts @@ -8,26 +8,31 @@ */ import _ from 'lodash'; -import type { SavedObjectsRawDoc, ISavedObjectTypeRegistry } from '@kbn/core-saved-objects-server'; +import type { SavedObjectsRawDoc } from '@kbn/core-saved-objects-server'; import { SavedObjectsSerializer } from './serializer'; import { encodeVersion } from '../version'; import { LEGACY_URL_ALIAS_TYPE } from '../legacy_alias'; +import type { ISavedObjectTypeRegistryInternal } from '../saved_objects_type_registry'; const createMockedTypeRegistry = ({ isNamespaceAgnostic, isSingleNamespace, isMultiNamespace, + accessControlEnabled = false, // default to false }: { isNamespaceAgnostic: boolean; isSingleNamespace: boolean; isMultiNamespace: boolean; -}): ISavedObjectTypeRegistry => { - const typeRegistry: Partial = { + accessControlEnabled?: boolean; +}): ISavedObjectTypeRegistryInternal => { + const typeRegistry: Partial = { isNamespaceAgnostic: jest.fn().mockReturnValue(isNamespaceAgnostic), isSingleNamespace: jest.fn().mockReturnValue(isSingleNamespace), isMultiNamespace: jest.fn().mockReturnValue(isMultiNamespace), + setAccessControlEnabled: jest.fn(), + isAccessControlEnabled: jest.fn().mockReturnValue(accessControlEnabled), }; - return typeRegistry as ISavedObjectTypeRegistry; + return typeRegistry as ISavedObjectTypeRegistryInternal; }; let typeRegistry = createMockedTypeRegistry({ @@ -51,6 +56,22 @@ typeRegistry = typeRegistry = createMockedTypeRegistry({ }); const multiNamespaceSerializer = new SavedObjectsSerializer(typeRegistry); +typeRegistry = typeRegistry = createMockedTypeRegistry({ + isNamespaceAgnostic: false, + isSingleNamespace: false, + isMultiNamespace: true, + accessControlEnabled: false, +}); +const accessControlDisabledSerializer = new SavedObjectsSerializer(typeRegistry); + +typeRegistry = typeRegistry = createMockedTypeRegistry({ + isNamespaceAgnostic: false, + isSingleNamespace: false, + isMultiNamespace: true, + accessControlEnabled: true, +}); +const accessControlEnabledSerializer = new SavedObjectsSerializer(typeRegistry); + const sampleTemplate = { _id: 'foo:bar', _source: { @@ -680,6 +701,53 @@ describe('#rawToSavedObject', () => { `"Expected document id to be a string but given [String] with [foo:bar] value."` ); }); + + describe('accessControl property', () => { + describe('Access control feature enabled', () => { + test('it copies the accessControl property from _source.accessControl', () => { + const actual = accessControlEnabledSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + accessControl: { + owner: 'my_user_id', + accessMode: 'write_restricted', + }, + }, + }); + expect(actual).toHaveProperty('accessControl', { + owner: 'my_user_id', + accessMode: 'write_restricted', + }); + }); + + test('it does not create the accessControl property if not present in _source.accessControl', () => { + const actual = accessControlEnabledSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + }, + }); + expect(actual).not.toHaveProperty('accessControl'); + }); + }); + + describe('Access control feature disabled', () => { + test('it strips the accessControl property if the feature is disabled', () => { + const actual = accessControlDisabledSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + accessControl: { + owner: 'my_user_id', + accessMode: 'write_restricted', + }, + }, + }); + expect(actual).not.toHaveProperty('accessControl'); + }); + }); + }); }); describe('#savedObjectToRaw', () => { @@ -960,6 +1028,30 @@ describe('#savedObjectToRaw', () => { expect(actual._source).toHaveProperty('namespaces', ['bar']); }); }); + + test('it copies accessControl to _source.accessControl', () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: 'foo', + accessControl: { + owner: 'my_user_id', + accessMode: 'write_restricted', + }, + attributes: {}, + } as any); + + expect(actual._source).toHaveProperty('accessControl', { + owner: 'my_user_id', + accessMode: 'write_restricted', + }); + }); + + test(`if _source.accessControl is unspecified it doesn't set accessControl`, () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: 'foo', + attributes: {}, + } as any); + expect(actual).not.toHaveProperty('accessControl'); + }); }); describe('#isRawSavedObject', () => { diff --git a/src/core/packages/saved-objects/base-server-internal/src/serialization/serializer.ts b/src/core/packages/saved-objects/base-server-internal/src/serialization/serializer.ts index 303a6ae5283b3..f2c5f28ca76a3 100644 --- a/src/core/packages/saved-objects/base-server-internal/src/serialization/serializer.ts +++ b/src/core/packages/saved-objects/base-server-internal/src/serialization/serializer.ts @@ -9,7 +9,6 @@ import typeDetect from 'type-detect'; import type { - ISavedObjectTypeRegistry, ISavedObjectsSerializer, SavedObjectsRawDoc, SavedObjectSanitizedDoc, @@ -18,6 +17,10 @@ import type { import { SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server'; import { LEGACY_URL_ALIAS_TYPE } from '../legacy_alias'; import { decodeVersion, encodeVersion } from '../version'; +import type { + ISavedObjectTypeRegistryInternal, + SavedObjectTypeRegistry, +} from '../saved_objects_type_registry'; /** * Core internal implementation of {@link ISavedObjectsSerializer} @@ -27,12 +30,12 @@ import { decodeVersion, encodeVersion } from '../version'; * @internal */ export class SavedObjectsSerializer implements ISavedObjectsSerializer { - private readonly registry: ISavedObjectTypeRegistry; + private readonly registry: ISavedObjectTypeRegistryInternal; /** * @internal */ - constructor(registry: ISavedObjectTypeRegistry) { + constructor(registry: ISavedObjectTypeRegistryInternal) { this.registry = registry; } @@ -127,6 +130,10 @@ export class SavedObjectsSerializer implements ISavedObjectsSerializer { ...(_source.created_at && { created_at: _source.created_at }), ...(_source.created_by && { created_by: _source.created_by }), ...(version && { version }), + ...((this.registry as SavedObjectTypeRegistry).isAccessControlEnabled() && + _source.accessControl && { + accessControl: _source.accessControl, + }), }; } @@ -154,6 +161,7 @@ export class SavedObjectsSerializer implements ISavedObjectsSerializer { coreMigrationVersion, typeMigrationVersion, managed, + accessControl, } = savedObj; const source = { [type]: attributes, @@ -170,6 +178,7 @@ export class SavedObjectsSerializer implements ISavedObjectsSerializer { ...(updatedBy && { updated_by: updatedBy }), ...(createdAt && { created_at: createdAt }), ...(createdBy && { created_by: createdBy }), + ...(accessControl && { accessControl }), }; return { _id: this.generateRawId(namespace, type, id), diff --git a/src/core/packages/saved-objects/base-server-internal/src/validation/schema.test.ts b/src/core/packages/saved-objects/base-server-internal/src/validation/schema.test.ts index 49af8efd0ba75..6905589351938 100644 --- a/src/core/packages/saved-objects/base-server-internal/src/validation/schema.test.ts +++ b/src/core/packages/saved-objects/base-server-internal/src/validation/schema.test.ts @@ -77,6 +77,10 @@ describe('Saved Objects type validation schema', () => { updated_at: '2022-01-05T03:17:07.183Z', version: '2', originId: 'def-456', + accessControl: { + accessMode: 'default', + owner: 'user1', + }, }) ).not.toThrowError(); }); @@ -114,5 +118,24 @@ describe('Saved Objects type validation schema', () => { `"[attributes]: expected value of type [object] but got [number]"` ); }); + + it('fails validation on incorrect access control', () => { + const objectSchema = createSavedObjectSanitizedDocSchema(undefined); + const data = createMockObject({ foo: 'heya' }); + + expect(() => + objectSchema.validate({ + ...data, + accessControl: { + owner: 'user1', + accessMode: 'invalid_mode', + }, + }) + ).toThrow( + `[accessControl.accessMode]: types that failed validation: +- [accessControl.accessMode.0]: expected value to equal [write_restricted] +- [accessControl.accessMode.1]: expected value to equal [default]` + ); + }); }); }); diff --git a/src/core/packages/saved-objects/base-server-internal/src/validation/schema.ts b/src/core/packages/saved-objects/base-server-internal/src/validation/schema.ts index 25e8e35e83b87..e69c774e75120 100644 --- a/src/core/packages/saved-objects/base-server-internal/src/validation/schema.ts +++ b/src/core/packages/saved-objects/base-server-internal/src/validation/schema.ts @@ -43,6 +43,12 @@ const baseSchema = schema.object({ version: schema.maybe(schema.string()), originId: schema.maybe(schema.string()), managed: schema.maybe(schema.boolean()), + accessControl: schema.maybe( + schema.object({ + owner: schema.string(), + accessMode: schema.oneOf([schema.literal('write_restricted'), schema.literal('default')]), + }) + ), attributes: schema.recordOf(schema.string(), schema.maybe(schema.any())), }); diff --git a/src/core/packages/saved-objects/base-server-mocks/src/saved_objects_type_registry.mock.ts b/src/core/packages/saved-objects/base-server-mocks/src/saved_objects_type_registry.mock.ts index 6c7df1b763cf6..3581e21db1464 100644 --- a/src/core/packages/saved-objects/base-server-mocks/src/saved_objects_type_registry.mock.ts +++ b/src/core/packages/saved-objects/base-server-mocks/src/saved_objects_type_registry.mock.ts @@ -7,13 +7,10 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { ISavedObjectTypeRegistry } from '@kbn/core-saved-objects-server'; -import { type SavedObjectTypeRegistry } from '@kbn/core-saved-objects-base-server-internal'; import { lazyObject } from '@kbn/lazy-object'; +import type { ISavedObjectTypeRegistryInternal } from '@kbn/core-saved-objects-base-server-internal'; -const createRegistryMock = (): jest.Mocked< - ISavedObjectTypeRegistry & Pick -> => { +const createRegistryMock = (): jest.Mocked => { const mock = lazyObject({ registerType: jest.fn(), getLegacyTypes: jest.fn().mockReturnValue([]), @@ -30,11 +27,34 @@ const createRegistryMock = (): jest.Mocked< isShareable: jest.fn().mockImplementation((type: string) => type === 'shared'), isHidden: jest.fn().mockReturnValue(false), isHiddenFromHttpApis: jest.fn(), - getIndex: jest.fn().mockReturnValue('.kibana-test'), - isImportableAndExportable: jest.fn().mockReturnValue(true), - getNameAttribute: jest.fn().mockReturnValue(undefined), + getIndex: jest.fn(), + isImportableAndExportable: jest.fn(), + getNameAttribute: jest.fn(), + supportsAccessControl: jest.fn(), + isAccessControlEnabled: jest.fn(), + setAccessControlEnabled: jest.fn(), }); + mock.getVisibleTypes.mockReturnValue([]); + mock.getAllTypes.mockReturnValue([]); + mock.getLegacyTypes.mockReturnValue([]); + mock.getImportableAndExportableTypes.mockReturnValue([]); + mock.getIndex.mockReturnValue('.kibana-test'); + mock.getIndex.mockReturnValue('.kibana-test'); + mock.isHidden.mockReturnValue(false); + mock.isHiddenFromHttpApis.mockReturnValue(false); + mock.isNamespaceAgnostic.mockImplementation((type: string) => type === 'global'); + mock.isSingleNamespace.mockImplementation( + (type: string) => type !== 'global' && type !== 'shared' + ); + mock.isMultiNamespace.mockImplementation((type: string) => type === 'shared'); + mock.isShareable.mockImplementation((type: string) => type === 'shared'); + mock.isImportableAndExportable.mockReturnValue(true); + mock.getVisibleToHttpApisTypes.mockReturnValue(false); + mock.getNameAttribute.mockReturnValue(undefined); + mock.supportsAccessControl.mockReturnValue(false); + mock.isAccessControlEnabled.mockReturnValue(true); + return mock; }; diff --git a/src/core/packages/saved-objects/common/index.ts b/src/core/packages/saved-objects/common/index.ts index f0b90ad894927..3e25456885890 100644 --- a/src/core/packages/saved-objects/common/index.ts +++ b/src/core/packages/saved-objects/common/index.ts @@ -9,6 +9,7 @@ export type { SavedObject, + SavedObjectAccessControl, SavedObjectsNamespaceType, SavedObjectAttributeSingle, SavedObjectAttribute, @@ -31,6 +32,7 @@ export type { SavedObjectsImportUnknownError, SavedObjectsImportActionRequiredWarning, SavedObjectsImportConflictError, + SavedObjectsImportUnexpectedAccessControlMetadataError, } from './src/saved_objects_imports'; export type { SavedObjectTypeIdTuple, LegacyUrlAliasTarget } from './src/types'; diff --git a/src/core/packages/saved-objects/common/src/saved_objects.ts b/src/core/packages/saved-objects/common/src/saved_objects.ts index c7cacc181f69d..fa0976cd71feb 100644 --- a/src/core/packages/saved-objects/common/src/saved_objects.ts +++ b/src/core/packages/saved-objects/common/src/saved_objects.ts @@ -79,3 +79,5 @@ export type SavedObjectReference = serverTypes.SavedObjectReference; * @deprecated See https://github.com/elastic/kibana/issues/149098. Import this type from @kbn/core/server instead. */ export type SavedObject = serverTypes.SavedObject; + +export type SavedObjectAccessControl = serverTypes.SavedObjectAccessControl; diff --git a/src/core/packages/saved-objects/common/src/saved_objects_imports.ts b/src/core/packages/saved-objects/common/src/saved_objects_imports.ts index f0c650507a703..8d6b32dc708dd 100644 --- a/src/core/packages/saved-objects/common/src/saved_objects_imports.ts +++ b/src/core/packages/saved-objects/common/src/saved_objects_imports.ts @@ -61,6 +61,38 @@ export interface SavedObjectsImportUnsupportedTypeError { type: 'unsupported_type'; } +// Note: the following two interfaces are not needed until we support importing +// access control metadata for admins (phase 2 of write-restricted dashboards). +// To be used once access control is enabled for imports: https://github.com/elastic/kibana/issues/242671 +/** + * Represents a failure to import due to missing access control mode metadata. + * This metadata is required only for objects that support access control when + * an Admin chooses to apply access control on import. + * @public + */ +// export interface SavedObjectsImportMissingAccessControlModeMetadataError { +// type: 'missing_access_control_mode'; +// } + +/** + * Represents a failure to import due to missing access control owner metadata. + * This metadata is required only for objects that support access control when + * an Admin chooses to apply access control on import. + * @public + */ +// export interface SavedObjectsImportMissingAccessControlOwnerMetadataError { +// type: 'missing_access_control_owner'; +// } + +/** + * Represents a failure to import due to unexpected access control metadata. + * This metadata is required only for objects that support access control. + * @public + */ +export interface SavedObjectsImportUnexpectedAccessControlMetadataError { + type: 'unexpected_access_control_metadata'; +} + /** * Represents a failure to import due to an unknown reason. * @public @@ -98,7 +130,8 @@ export interface SavedObjectsImportFailure { | SavedObjectsImportAmbiguousConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError - | SavedObjectsImportUnknownError; + | SavedObjectsImportUnknownError + | SavedObjectsImportUnexpectedAccessControlMetadataError; } /** diff --git a/src/core/packages/saved-objects/common/src/server_types.ts b/src/core/packages/saved-objects/common/src/server_types.ts index b1e853261bc3b..95713f4ed7fb8 100644 --- a/src/core/packages/saved-objects/common/src/server_types.ts +++ b/src/core/packages/saved-objects/common/src/server_types.ts @@ -37,6 +37,22 @@ export type SavedObjectAttributeSingle = */ export type SavedObjectAttribute = SavedObjectAttributeSingle | SavedObjectAttributeSingle[]; +/** + * Definition of the Saved Object access control interface + * + * @public + */ + +export interface SavedObjectAccessControl { + /** The ID of the user who owns this object. */ + owner: string; + /** + * The access mode of the object. `write_restricted` is editable only by the owner and admin users. + * Access mode `default` is editable by all users with write access to the object. + */ + accessMode: 'write_restricted' | 'default'; +} + /** * The data for a Saved Object is stored as an object in the `attributes` * property. @@ -114,6 +130,14 @@ export interface SavedObject { * make their edits to the copy. */ managed?: boolean; + + /** + * Access control information of the saved object. + * This can be be used to customize access to the object in addition to RBAC, e.g. + * to set an object to read-only mode, where it is only editable by the owner of + * the object (or an admin), even if other users are granted write access via a role. + */ + accessControl?: SavedObjectAccessControl; } /** @@ -134,6 +158,6 @@ export interface SavedObjectsRawDocSource { references?: SavedObjectReference[]; originId?: string; managed?: boolean; - + accessControl?: SavedObjectAccessControl; [typeMapping: string]: any; } diff --git a/src/core/packages/saved-objects/import-export-server-internal/src/export/saved_objects_exporter.test.ts b/src/core/packages/saved-objects/import-export-server-internal/src/export/saved_objects_exporter.test.ts index 7b39a563beacd..4885d31a8d879 100644 --- a/src/core/packages/saved-objects/import-export-server-internal/src/export/saved_objects_exporter.test.ts +++ b/src/core/packages/saved-objects/import-export-server-internal/src/export/saved_objects_exporter.test.ts @@ -1039,6 +1039,130 @@ describe('getSortedObjectsForExport()', () => { }) ); }); + + // Unskip after: https://github.com/elastic/kibana/issues/242671 + describe.skip('access control', () => { + test('applies the access control transform if defined', async () => { + // const accessControlExportTransform: SavedObjectsExportTransform = (ctx, objects) => { + // objects.forEach((obj: SavedObject) => { + // if (typeRegistry.supportsAccessControl(obj.type)) + // obj.attributes.foo = 'modified by access control transform'; + // }); + // return objects; + // }; + + typeRegistry.registerType({ + name: 'foo', + mappings: { properties: {} }, + namespaceType: 'single', + hidden: false, + supportsAccessControl: true, + management: { + importableAndExportable: true, + onExport: (ctx, objects) => { + objects.forEach((obj: SavedObject) => { + obj.attributes.foo = 'modified'; + }); + return objects; + }, + }, + }); + exporter = new SavedObjectsExporter({ + exportSizeLimit, + logger, + savedObjectsClient, + typeRegistry, + }); + + savedObjectsClient.find.mockResolvedValueOnce({ + total: 1, + saved_objects: [ + { + id: '1', + type: 'foo', + attributes: { + foo: 'initial', + }, + score: 0, + references: [], + }, + ], + per_page: 1, + page: 0, + }); + const exportStream = await exporter.exportByTypes({ + request, + types: ['foo'], + excludeExportDetails: true, + }); + + const response = await readStreamToCompletion(exportStream); + + expect(response).toHaveLength(1); + expect(response[0].attributes.foo).toEqual('modified by access control transform'); + }); + + test('does not apply the access control transform to types that do not support access control', async () => { + // const accessControlExportTransform: SavedObjectsExportTransform = (ctx, objects) => { + // objects.forEach((obj: SavedObject) => { + // if (typeRegistry.supportsAccessControl(obj.type)) + // obj.attributes.foo = 'modified by access control transform'; + // }); + // return objects; + // }; + + typeRegistry.registerType({ + name: 'foo', + mappings: { properties: {} }, + namespaceType: 'single', + hidden: false, + supportsAccessControl: false, + management: { + importableAndExportable: true, + onExport: (ctx, objects) => { + objects.forEach((obj: SavedObject) => { + obj.attributes.foo = 'modified by type onExport'; + }); + return objects; + }, + }, + }); + exporter = new SavedObjectsExporter({ + exportSizeLimit, + logger, + savedObjectsClient, + typeRegistry, + // accessControlExportTransform, + }); + + savedObjectsClient.find.mockResolvedValueOnce({ + total: 1, + saved_objects: [ + { + id: '1', + type: 'foo', + attributes: { + foo: 'initial', + }, + score: 0, + references: [], + }, + ], + per_page: 1, + page: 0, + }); + const exportStream = await exporter.exportByTypes({ + request, + types: ['foo'], + excludeExportDetails: true, + }); + + const response = await readStreamToCompletion(exportStream); + + expect(response).toHaveLength(1); + expect(response[0].attributes.foo).toEqual('modified by type onExport'); + }); + }); }); describe('#exportByObjects', () => { @@ -1357,5 +1481,130 @@ describe('getSortedObjectsForExport()', () => { } `); }); + + // Unskip after: https://github.com/elastic/kibana/issues/242671 + describe.skip('access control', () => { + test('applies the access control transform to supporting types if defined', async () => { + // const accessControlExportTransform: SavedObjectsExportTransform = (ctx, objects) => { + // objects.forEach((obj: SavedObject) => { + // if (typeRegistry.supportsAccessControl(obj.type)) + // obj.attributes.foo = 'modified by access control transform'; + // }); + // return objects; + // }; + + typeRegistry.registerType({ + name: 'foo', + mappings: { properties: {} }, + namespaceType: 'single', + hidden: false, + supportsAccessControl: true, + management: { + importableAndExportable: true, + onExport: (ctx, objects) => { + objects.forEach((obj: SavedObject) => { + obj.attributes.foo = 'modified'; + }); + return objects; + }, + }, + }); + + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + { + id: '1', + type: 'index-pattern', + attributes: {}, + references: [], + }, + { + id: '2', + type: 'foo', + attributes: { + foo: 'initial', + }, + references: [], + }, + { + id: '3', + type: 'search', + attributes: {}, + references: [ + { + id: '1', + name: 'name', + type: 'index-pattern', + }, + ], + }, + ], + }); + + exporter = new SavedObjectsExporter({ + exportSizeLimit, + logger, + savedObjectsClient, + typeRegistry, + // accessControlExportTransform, + }); + + const exportStream = await exporter.exportByObjects({ + request, + objects: [ + { + type: 'index-pattern', + id: '1', + }, + { + type: 'foo', + id: '2', + }, + { + type: 'search', + id: '3', + }, + ], + }); + const response = await readStreamToCompletion(exportStream); + expect(response).toMatchInlineSnapshot(` + Array [ + Object { + "attributes": Object {}, + "id": "1", + "references": Array [], + "type": "index-pattern", + }, + Object { + "attributes": Object { + "foo": "modified by access control transform", + }, + "id": "2", + "references": Array [], + "type": "foo", + }, + Object { + "attributes": Object {}, + "id": "3", + "references": Array [ + Object { + "id": "1", + "name": "name", + "type": "index-pattern", + }, + ], + "type": "search", + }, + Object { + "excludedObjects": Array [], + "excludedObjectsCount": 0, + "exportedCount": 3, + "missingRefCount": 0, + "missingReferences": Array [], + }, + ] + `); + }); + }); }); }); diff --git a/src/core/packages/saved-objects/import-export-server-internal/src/import/import_saved_objects.test.ts b/src/core/packages/saved-objects/import-export-server-internal/src/import/import_saved_objects.test.ts index 06c8e351bc445..e6d66e75d7050 100644 --- a/src/core/packages/saved-objects/import-export-server-internal/src/import/import_saved_objects.test.ts +++ b/src/core/packages/saved-objects/import-export-server-internal/src/import/import_saved_objects.test.ts @@ -18,7 +18,7 @@ import { mockExecuteImportHooks, } from './import_saved_objects.test.mock'; -import { Readable } from 'stream'; +import { PassThrough, Readable } from 'stream'; import { loggerMock, type MockedLogger } from '@kbn/logging-mocks'; import { v4 as uuidv4 } from 'uuid'; import type { @@ -31,6 +31,8 @@ import type { ISavedObjectTypeRegistry, SavedObjectsImportHook, SavedObject, + AccessControlImportTransforms, + AccessControlImportTransformsFactory, } from '@kbn/core-saved-objects-server'; import { typeRegistryMock } from '@kbn/core-saved-objects-base-server-mocks'; import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; @@ -40,6 +42,16 @@ import { } from './import_saved_objects'; import type { ImportStateMap } from './lib'; +// Simple implementation of createAccessControlImportTransforms just so we can test that it is called +const createAccessControlImportTransforms: AccessControlImportTransformsFactory = ( + registry: ISavedObjectTypeRegistry, + errors: SavedObjectsImportFailure[] +): AccessControlImportTransforms => { + return { + filterStream: new PassThrough(), + } as AccessControlImportTransforms; +}; + describe('#importSavedObjectsFromStream', () => { let logger: MockedLogger; beforeEach(() => { @@ -84,11 +96,13 @@ describe('#importSavedObjectsFromStream', () => { } as any), importHooks = {}, managed, + accessControl, }: { createNewCopies?: boolean; getTypeImpl?: (name: string) => any; importHooks?: Record; managed?: boolean; + accessControl?: boolean; } = {}): ImportSavedObjectsOptions => { readStream = new Readable(); savedObjectsClient = savedObjectsClientMock.create(); @@ -105,6 +119,9 @@ describe('#importSavedObjectsFromStream', () => { importHooks, managed, log: logger, + ...(accessControl && { + createAccessControlImportTransforms, + }), }; }; const createObject = ({ @@ -149,7 +166,13 @@ describe('#importSavedObjectsFromStream', () => { await importSavedObjectsFromStream(options); expect(typeRegistry.getImportableAndExportableTypes).toHaveBeenCalled(); - const collectSavedObjectsOptions = { readStream, objectLimit, supportedTypes }; + const collectSavedObjectsOptions = { + readStream, + objectLimit, + supportedTypes, + createAccessControlImportTransforms: undefined, + typeRegistry, + }; expect(mockCollectSavedObjects).toHaveBeenCalledWith(collectSavedObjectsOptions); }); @@ -554,6 +577,45 @@ describe('#importSavedObjectsFromStream', () => { expect(mockCreateSavedObjects).toHaveBeenCalledWith(createSavedObjectsParams); }); }); + + describe('access control', () => { + test(`calls 'collectSavedObjects' with createAccessControlImportTransforms function if provided`, async () => { + const options = setupOptions({ accessControl: true }); + const supportedTypes = ['foo-type']; + typeRegistry.getImportableAndExportableTypes.mockReturnValue( + supportedTypes.map((name) => ({ name })) as SavedObjectsType[] + ); + + await importSavedObjectsFromStream(options); + expect(typeRegistry.getImportableAndExportableTypes).toHaveBeenCalled(); + const collectSavedObjectsOptions = { + readStream, + objectLimit, + supportedTypes, + typeRegistry, + createAccessControlImportTransforms, + }; + expect(mockCollectSavedObjects).toHaveBeenCalledWith(collectSavedObjectsOptions); + }); + + test(`does not call 'collectSavedObjects' with createAccessControlImportTransforms function if undefined`, async () => { + const options = setupOptions({ accessControl: false }); + const supportedTypes = ['foo-type']; + typeRegistry.getImportableAndExportableTypes.mockReturnValue( + supportedTypes.map((name) => ({ name })) as SavedObjectsType[] + ); + + await importSavedObjectsFromStream(options); + expect(typeRegistry.getImportableAndExportableTypes).toHaveBeenCalled(); + const collectSavedObjectsOptions = { + readStream, + objectLimit, + supportedTypes, + typeRegistry, + }; + expect(mockCollectSavedObjects).toHaveBeenCalledWith(collectSavedObjectsOptions); + }); + }); }); describe('results', () => { diff --git a/src/core/packages/saved-objects/import-export-server-internal/src/import/import_saved_objects.ts b/src/core/packages/saved-objects/import-export-server-internal/src/import/import_saved_objects.ts index 2e4386a4fbd9b..2ee9ceb338e31 100644 --- a/src/core/packages/saved-objects/import-export-server-internal/src/import/import_saved_objects.ts +++ b/src/core/packages/saved-objects/import-export-server-internal/src/import/import_saved_objects.ts @@ -14,6 +14,7 @@ import type { } from '@kbn/core-saved-objects-common'; import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; import type { + AccessControlImportTransformsFactory, ISavedObjectTypeRegistry, SavedObjectsImportHook, } from '@kbn/core-saved-objects-server'; @@ -60,6 +61,9 @@ export interface ImportSavedObjectsOptions { * If provided, Kibana will apply the given option to the `managed` property. */ managed?: boolean; + /** The factory function for creating the access control import transforms */ + createAccessControlImportTransforms?: AccessControlImportTransformsFactory; + /** The logger to use during the import operation */ log: Logger; } @@ -82,6 +86,7 @@ export async function importSavedObjectsFromStream({ compatibilityMode, managed, log, + createAccessControlImportTransforms, }: ImportSavedObjectsOptions): Promise { log.debug( `Importing with overwrite ${overwrite ? 'enabled' : 'disabled'} and size limit ${objectLimit}` @@ -95,6 +100,8 @@ export async function importSavedObjectsFromStream({ objectLimit, supportedTypes, managed, + typeRegistry, + createAccessControlImportTransforms, }); log.debug( `Importing types: ${[ diff --git a/src/core/packages/saved-objects/import-export-server-internal/src/import/lib/collect_saved_objects.test.ts b/src/core/packages/saved-objects/import-export-server-internal/src/import/lib/collect_saved_objects.test.ts index 7a5d658d1066f..4b82d2f79e61c 100644 --- a/src/core/packages/saved-objects/import-export-server-internal/src/import/lib/collect_saved_objects.test.ts +++ b/src/core/packages/saved-objects/import-export-server-internal/src/import/lib/collect_saved_objects.test.ts @@ -12,6 +12,15 @@ import { SavedObjectsImportError } from '../errors'; import { collectSavedObjects } from './collect_saved_objects'; import { createLimitStream } from './create_limit_stream'; import { getNonUniqueEntries } from './get_non_unique_entries'; +import { typeRegistryMock } from '@kbn/core-saved-objects-base-server-mocks'; +import type { + AccessControlImportTransforms, + AccessControlImportTransformsFactory, + ISavedObjectTypeRegistry, + SavedObject, +} from '@kbn/core-saved-objects-server'; +import type { SavedObjectsImportFailure } from '@kbn/core-saved-objects-common'; +import { createFilterStream } from '@kbn/utils'; jest.mock('./create_limit_stream'); jest.mock('./get_non_unique_entries'); @@ -20,6 +29,36 @@ const getMockFn = any, U>(fn: (...args: Parameter fn as jest.MockedFunction<(...args: Parameters) => U>; let limitStreamPush: jest.SpyInstance; +let typeRegistry: jest.Mocked; + +const READ_ONLY_TYPE = 'read-only-type'; + +// Simple implementation of createAccessControlImportTransforms just so we can test that it is called +const createAccessControlImportTransforms: AccessControlImportTransformsFactory = ( + registry: ISavedObjectTypeRegistry, + errors: SavedObjectsImportFailure[] +): AccessControlImportTransforms => { + return { + filterStream: createFilterStream>((obj) => { + const typeSupportsAccessControl = registry.supportsAccessControl(obj.type); + const { title } = obj.attributes; + + if (typeSupportsAccessControl) { + errors.push({ + id: obj.id, + type: obj.type, + meta: { title }, + error: { + type: 'unexpected_access_control_metadata', + }, + }); + return false; + } + + return true; + }), + } as AccessControlImportTransforms; +}; beforeEach(() => { jest.clearAllMocks(); @@ -27,6 +66,9 @@ beforeEach(() => { limitStreamPush = jest.spyOn(stream, 'push'); getMockFn(createLimitStream).mockReturnValue(stream); getMockFn(getNonUniqueEntries).mockReturnValue([]); + + typeRegistry = typeRegistryMock.create(); + typeRegistry.supportsAccessControl.mockImplementation((type: string) => type === READ_ONLY_TYPE); }); describe('collectSavedObjects()', () => { @@ -59,11 +101,23 @@ describe('collectSavedObjects()', () => { references: [{ type: 'b', id: '2', name: 'b2' }], managed: true, }; + const obj4 = { + type: READ_ONLY_TYPE, + id: 'ro1', + attributes: { title: 'my title 4' }, + references: [], + supportsAccessControl: true, + }; describe('module calls', () => { test('limit stream with empty input stream is called with null', async () => { const readStream = createReadStream(); - await collectSavedObjects({ readStream, supportedTypes: [], objectLimit }); + await collectSavedObjects({ + readStream, + supportedTypes: [], + objectLimit, + typeRegistry, + }); expect(createLimitStream).toHaveBeenCalledWith(objectLimit); expect(limitStreamPush).toHaveBeenCalledTimes(1); @@ -73,7 +127,7 @@ describe('collectSavedObjects()', () => { test('limit stream with non-empty input stream is called with all objects', async () => { const readStream = createReadStream(obj1, obj2, obj3); const supportedTypes = [obj2.type]; - await collectSavedObjects({ readStream, supportedTypes, objectLimit }); + await collectSavedObjects({ readStream, supportedTypes, objectLimit, typeRegistry }); expect(createLimitStream).toHaveBeenCalledWith(objectLimit); expect(limitStreamPush).toHaveBeenCalledTimes(4); @@ -85,7 +139,12 @@ describe('collectSavedObjects()', () => { test('get non-unique entries with empty input stream is called with empty array', async () => { const readStream = createReadStream(); - await collectSavedObjects({ readStream, supportedTypes: [], objectLimit }); + await collectSavedObjects({ + readStream, + supportedTypes: [], + objectLimit, + typeRegistry, + }); expect(getNonUniqueEntries).toHaveBeenCalledWith([]); }); @@ -93,7 +152,7 @@ describe('collectSavedObjects()', () => { test('get non-unique entries with non-empty input stream is called with all entries', async () => { const readStream = createReadStream(obj1, obj2, obj3); const supportedTypes = [obj2.type]; - await collectSavedObjects({ readStream, supportedTypes, objectLimit }); + await collectSavedObjects({ readStream, supportedTypes, objectLimit, typeRegistry }); expect(getNonUniqueEntries).toHaveBeenCalledWith([ { type: obj1.type, id: obj1.id }, @@ -105,7 +164,13 @@ describe('collectSavedObjects()', () => { test('filter with empty input stream is not called', async () => { const readStream = createReadStream(); const filter = jest.fn(); - await collectSavedObjects({ readStream, supportedTypes: [], objectLimit, filter }); + await collectSavedObjects({ + readStream, + supportedTypes: [], + objectLimit, + filter, + typeRegistry, + }); expect(filter).not.toHaveBeenCalled(); }); @@ -114,11 +179,67 @@ describe('collectSavedObjects()', () => { const readStream = createReadStream(obj1, obj2, obj3); const filter = jest.fn(); const supportedTypes = [obj2.type]; - await collectSavedObjects({ readStream, supportedTypes, objectLimit, filter }); + await collectSavedObjects({ + readStream, + supportedTypes, + objectLimit, + filter, + typeRegistry, + }); expect(filter).toHaveBeenCalledTimes(1); expect(filter).toHaveBeenCalledWith(obj2); }); + + describe('access control', () => { + test('calls access control filter when create transforms function is provided', async () => { + const readStream = createReadStream(obj1, obj4); + const supportedTypes = [obj1.type, obj4.type]; + const result = await collectSavedObjects({ + readStream, + supportedTypes, + objectLimit, + typeRegistry, + createAccessControlImportTransforms, + }); + + const collectedObjects = [{ ...obj1, typeMigrationVersion: '', managed: false }]; + + const importStateMap = new Map([ + [`a:1`, {}], + [`b:2`, { isOnlyReference: true }], + ]); + + const error = { type: 'unexpected_access_control_metadata' }; + const { title } = obj4.attributes; + const errors = [{ error, type: obj4.type, id: obj4.id, meta: { title } }]; + expect(result).toEqual({ collectedObjects, errors, importStateMap }); + }); + + test('does not call access control filter when create transforms function is not provided', async () => { + const readStream = createReadStream(obj1, obj4); + const supportedTypes = [obj1.type, obj4.type]; + const result = await collectSavedObjects({ + readStream, + supportedTypes, + objectLimit, + typeRegistry, + }); + + const collectedObjects = [ + { ...obj1, typeMigrationVersion: '', managed: false }, + { ...obj4, typeMigrationVersion: '', managed: false }, + ]; + + const importStateMap = new Map([ + [`a:1`, {}], + [`b:2`, { isOnlyReference: true }], + [`${READ_ONLY_TYPE}:ro1`, {}], + ]); + + expect(result).toEqual({ collectedObjects, errors: [], importStateMap }); + }); + }); }); describe('results', () => { @@ -127,7 +248,12 @@ describe('collectSavedObjects()', () => { const readStream = createReadStream(); expect.assertions(2); try { - await collectSavedObjects({ readStream, supportedTypes: [], objectLimit }); + await collectSavedObjects({ + readStream, + supportedTypes: [], + objectLimit, + typeRegistry, + }); } catch (e) { expect(e).toBeInstanceOf(SavedObjectsImportError); expect(e.message).toMatchInlineSnapshot( @@ -138,7 +264,12 @@ describe('collectSavedObjects()', () => { test('collects nothing when stream is empty', async () => { const readStream = createReadStream(); - const result = await collectSavedObjects({ readStream, supportedTypes: [], objectLimit }); + const result = await collectSavedObjects({ + readStream, + supportedTypes: [], + objectLimit, + typeRegistry, + }); expect(result).toEqual({ collectedObjects: [], errors: [], importStateMap: new Map() }); }); @@ -146,7 +277,12 @@ describe('collectSavedObjects()', () => { test('collects objects from stream', async () => { const readStream = createReadStream(obj1, obj2); const supportedTypes = [obj1.type, obj2.type]; - const result = await collectSavedObjects({ readStream, supportedTypes, objectLimit }); + const result = await collectSavedObjects({ + readStream, + supportedTypes, + objectLimit, + typeRegistry, + }); const collectedObjects = [ { ...obj1, typeMigrationVersion: '', managed: false }, @@ -163,7 +299,12 @@ describe('collectSavedObjects()', () => { test('unsupported types return as import errors', async () => { const readStream = createReadStream(obj1); const supportedTypes = ['not-obj1-type']; - const result = await collectSavedObjects({ readStream, supportedTypes, objectLimit }); + const result = await collectSavedObjects({ + readStream, + supportedTypes, + objectLimit, + typeRegistry, + }); const error = { type: 'unsupported_type' }; const { title } = obj1.attributes; @@ -174,7 +315,12 @@ describe('collectSavedObjects()', () => { test('returns mixed results', async () => { const readStream = createReadStream(obj1, obj2); const supportedTypes = [obj1.type]; - const result = await collectSavedObjects({ readStream, supportedTypes, objectLimit }); + const result = await collectSavedObjects({ + readStream, + supportedTypes, + objectLimit, + typeRegistry, + }); const collectedObjects = [{ ...obj1, typeMigrationVersion: '', managed: false }]; const importStateMap = new Map([ @@ -196,7 +342,12 @@ describe('collectSavedObjects()', () => { const readStream = createReadStream(...collectedObjects); const supportedTypes = [obj1.type, obj2.type]; - const result = await collectSavedObjects({ readStream, supportedTypes, objectLimit }); + const result = await collectSavedObjects({ + readStream, + supportedTypes, + objectLimit, + typeRegistry, + }); expect(result).toEqual(expect.objectContaining({ collectedObjects })); }); @@ -211,6 +362,7 @@ describe('collectSavedObjects()', () => { supportedTypes, objectLimit, filter, + typeRegistry, }); const error = { type: 'unsupported_type' }; @@ -229,6 +381,7 @@ describe('collectSavedObjects()', () => { objectLimit, filter, managed: false, + typeRegistry, }); const collectedObjects = [{ ...obj2, typeMigrationVersion: '', managed: false }]; diff --git a/src/core/packages/saved-objects/import-export-server-internal/src/import/lib/collect_saved_objects.ts b/src/core/packages/saved-objects/import-export-server-internal/src/import/lib/collect_saved_objects.ts index 998cd74dd85c9..608eb3288d9d1 100644 --- a/src/core/packages/saved-objects/import-export-server-internal/src/import/lib/collect_saved_objects.ts +++ b/src/core/packages/saved-objects/import-export-server-internal/src/import/lib/collect_saved_objects.ts @@ -15,7 +15,8 @@ import { createPromiseFromStreams, } from '@kbn/utils'; import type { SavedObjectsImportFailure } from '@kbn/core-saved-objects-common'; -import type { SavedObject } from '@kbn/core-saved-objects-server'; +import type { ISavedObjectTypeRegistry, SavedObject } from '@kbn/core-saved-objects-server'; +import type { AccessControlImportTransformsFactory } from '@kbn/core-saved-objects-server/src/import'; import { SavedObjectsImportError } from '../errors'; import { getNonUniqueEntries } from './get_non_unique_entries'; import { createLimitStream } from './create_limit_stream'; @@ -27,6 +28,8 @@ interface CollectSavedObjectsOptions { filter?: (obj: SavedObject) => boolean; supportedTypes: string[]; managed?: boolean; + typeRegistry: ISavedObjectTypeRegistry; + createAccessControlImportTransforms?: AccessControlImportTransformsFactory; } export async function collectSavedObjects({ @@ -35,10 +38,15 @@ export async function collectSavedObjects({ filter, supportedTypes, managed, + typeRegistry, + createAccessControlImportTransforms, }: CollectSavedObjectsOptions) { const errors: SavedObjectsImportFailure[] = []; const entries: Array<{ type: string; id: string }> = []; const importStateMap: ImportStateMap = new Map(); + + const accessControlTransforms = createAccessControlImportTransforms?.(typeRegistry, errors); + const collectedObjects: Array> = await createPromiseFromStreams([ readStream, createLimitStream(objectLimit), @@ -58,6 +66,8 @@ export async function collectSavedObjects({ }); return false; }), + ...(accessControlTransforms?.filterStream ? [accessControlTransforms.filterStream] : []), + ...(accessControlTransforms?.mapStream ? [accessControlTransforms.mapStream] : []), createFilterStream((obj) => (filter ? filter(obj) : true)), createMapStream((obj: SavedObject) => { importStateMap.set(`${obj.type}:${obj.id}`, {}); diff --git a/src/core/packages/saved-objects/import-export-server-internal/src/import/resolve_import_errors.test.ts b/src/core/packages/saved-objects/import-export-server-internal/src/import/resolve_import_errors.test.ts index 1460e21044d1a..0390a20124455 100644 --- a/src/core/packages/saved-objects/import-export-server-internal/src/import/resolve_import_errors.test.ts +++ b/src/core/packages/saved-objects/import-export-server-internal/src/import/resolve_import_errors.test.ts @@ -188,7 +188,13 @@ describe('#importSavedObjectsFromStream', () => { await resolveSavedObjectsImportErrors(options); expect(typeRegistry.getImportableAndExportableTypes).toHaveBeenCalled(); const filter = mockCreateObjectsFilter.mock.results[0].value; - const mockCollectSavedObjectsOptions = { readStream, objectLimit, filter, supportedTypes }; + const mockCollectSavedObjectsOptions = { + readStream, + objectLimit, + filter, + supportedTypes, + typeRegistry, + }; expect(mockCollectSavedObjects).toHaveBeenCalledWith(mockCollectSavedObjectsOptions); }); diff --git a/src/core/packages/saved-objects/import-export-server-internal/src/import/resolve_import_errors.ts b/src/core/packages/saved-objects/import-export-server-internal/src/import/resolve_import_errors.ts index 2aca2653923b9..eeded44b495ba 100644 --- a/src/core/packages/saved-objects/import-export-server-internal/src/import/resolve_import_errors.ts +++ b/src/core/packages/saved-objects/import-export-server-internal/src/import/resolve_import_errors.ts @@ -19,6 +19,7 @@ import type { ISavedObjectTypeRegistry, SavedObjectsImportHook, SavedObject, + AccessControlImportTransformsFactory, } from '@kbn/core-saved-objects-server'; import { collectSavedObjects, @@ -65,6 +66,8 @@ export interface ResolveSavedObjectsImportErrorsOptions { * This property allows plugin authors to implement read-only UI's */ managed?: boolean; + /** The factory function for creating the access control import transforms */ + createAccessControlImportTransforms?: AccessControlImportTransformsFactory; } /** @@ -84,6 +87,7 @@ export async function resolveSavedObjectsImportErrors({ createNewCopies, compatibilityMode, managed, + createAccessControlImportTransforms, }: ResolveSavedObjectsImportErrorsOptions): Promise { // throw a BadRequest error if we see invalid retries validateRetries(retries); @@ -100,6 +104,8 @@ export async function resolveSavedObjectsImportErrors({ filter, supportedTypes, managed, + typeRegistry, + createAccessControlImportTransforms, }); // Map of all IDs for objects that we are attempting to import, and any references that are not included in the read stream; // each value is empty by default diff --git a/src/core/packages/saved-objects/import-export-server-internal/src/import/saved_objects_importer.ts b/src/core/packages/saved-objects/import-export-server-internal/src/import/saved_objects_importer.ts index e8c13180bbdaf..b295d749857b8 100644 --- a/src/core/packages/saved-objects/import-export-server-internal/src/import/saved_objects_importer.ts +++ b/src/core/packages/saved-objects/import-export-server-internal/src/import/saved_objects_importer.ts @@ -17,6 +17,7 @@ import type { SavedObjectsResolveImportErrorsOptions, SavedObjectsImportHook, } from '@kbn/core-saved-objects-server'; +import type { AccessControlImportTransformsFactory } from '@kbn/core-saved-objects-server/src/import'; import { importSavedObjectsFromStream } from './import_saved_objects'; import { resolveSavedObjectsImportErrors } from './resolve_import_errors'; @@ -29,17 +30,20 @@ export class SavedObjectsImporter implements ISavedObjectsImporter { readonly #importSizeLimit: number; readonly #importHooks: Record; readonly #log: Logger; + readonly #createAccessControlImportTransforms?: AccessControlImportTransformsFactory; constructor({ savedObjectsClient, typeRegistry, importSizeLimit, logger, + createAccessControlImportTransforms, }: { savedObjectsClient: SavedObjectsClientContract; typeRegistry: ISavedObjectTypeRegistry; importSizeLimit: number; logger: Logger; + createAccessControlImportTransforms?: AccessControlImportTransformsFactory; }) { this.#savedObjectsClient = savedObjectsClient; this.#typeRegistry = typeRegistry; @@ -51,6 +55,7 @@ export class SavedObjectsImporter implements ISavedObjectsImporter { return hooks; }, {} as Record); this.#log = logger; + this.#createAccessControlImportTransforms = createAccessControlImportTransforms; } public import({ @@ -75,6 +80,7 @@ export class SavedObjectsImporter implements ISavedObjectsImporter { importHooks: this.#importHooks, managed, log: this.#log, + createAccessControlImportTransforms: this.#createAccessControlImportTransforms, }); } @@ -98,6 +104,7 @@ export class SavedObjectsImporter implements ISavedObjectsImporter { typeRegistry: this.#typeRegistry, importHooks: this.#importHooks, managed, + createAccessControlImportTransforms: this.#createAccessControlImportTransforms, }); } } diff --git a/src/core/packages/saved-objects/migration-server-internal/src/__snapshots__/kibana_migrator.test.ts.snap b/src/core/packages/saved-objects/migration-server-internal/src/__snapshots__/kibana_migrator.test.ts.snap index 402826137b359..a11f33890e568 100644 --- a/src/core/packages/saved-objects/migration-server-internal/src/__snapshots__/kibana_migrator.test.ts.snap +++ b/src/core/packages/saved-objects/migration-server-internal/src/__snapshots__/kibana_migrator.test.ts.snap @@ -4,6 +4,14 @@ exports[`KibanaMigrator getActiveMappings returns full index mappings w/ core pr Object { "dynamic": "strict", "properties": Object { + "accessControl": Object { + "dynamic": "false", + "properties": Object { + "owner": Object { + "type": "keyword", + }, + }, + }, "amap": Object { "properties": Object { "field": Object { diff --git a/src/core/packages/saved-objects/migration-server-internal/src/core/__snapshots__/build_active_mappings.test.ts.snap b/src/core/packages/saved-objects/migration-server-internal/src/core/__snapshots__/build_active_mappings.test.ts.snap index 614f766133e19..1ed71660f7abe 100644 --- a/src/core/packages/saved-objects/migration-server-internal/src/core/__snapshots__/build_active_mappings.test.ts.snap +++ b/src/core/packages/saved-objects/migration-server-internal/src/core/__snapshots__/build_active_mappings.test.ts.snap @@ -7,6 +7,14 @@ Object { "aaa": Object { "type": "text", }, + "accessControl": Object { + "dynamic": "false", + "properties": Object { + "owner": Object { + "type": "keyword", + }, + }, + }, "bbb": Object { "type": "long", }, @@ -65,6 +73,14 @@ exports[`buildActiveMappings handles the \`dynamic\` property of types 1`] = ` Object { "dynamic": "strict", "properties": Object { + "accessControl": Object { + "dynamic": "false", + "properties": Object { + "owner": Object { + "type": "keyword", + }, + }, + }, "coreMigrationVersion": Object { "type": "keyword", }, diff --git a/src/core/packages/saved-objects/migration-server-internal/src/core/build_active_mappings.test.ts b/src/core/packages/saved-objects/migration-server-internal/src/core/build_active_mappings.test.ts index e3f3a6745862c..d22839c465969 100644 --- a/src/core/packages/saved-objects/migration-server-internal/src/core/build_active_mappings.test.ts +++ b/src/core/packages/saved-objects/migration-server-internal/src/core/build_active_mappings.test.ts @@ -132,6 +132,14 @@ describe('getBaseMappings', () => { managed: { type: 'boolean', }, + accessControl: { + dynamic: 'false', + properties: { + owner: { + type: 'keyword', + }, + }, + }, }, }); }); diff --git a/src/core/packages/saved-objects/migration-server-internal/src/core/build_active_mappings.ts b/src/core/packages/saved-objects/migration-server-internal/src/core/build_active_mappings.ts index c502d23186055..7fd2eb9db319a 100644 --- a/src/core/packages/saved-objects/migration-server-internal/src/core/build_active_mappings.ts +++ b/src/core/packages/saved-objects/migration-server-internal/src/core/build_active_mappings.ts @@ -96,6 +96,14 @@ export function getBaseMappings(): IndexMappingSafe { managed: { type: 'boolean', }, + accessControl: { + dynamic: 'false', + properties: { + owner: { + type: 'keyword', + }, + }, + }, }, }; } diff --git a/src/core/packages/saved-objects/migration-server-internal/src/kibana_migrator.ts b/src/core/packages/saved-objects/migration-server-internal/src/kibana_migrator.ts index 7c1d06bbc2479..85265d7f8b3b9 100644 --- a/src/core/packages/saved-objects/migration-server-internal/src/kibana_migrator.ts +++ b/src/core/packages/saved-objects/migration-server-internal/src/kibana_migrator.ts @@ -21,14 +21,12 @@ import type { ElasticsearchCapabilities, ElasticsearchClient, } from '@kbn/core-elasticsearch-server'; -import type { - ISavedObjectTypeRegistry, - SavedObjectUnsanitizedDoc, -} from '@kbn/core-saved-objects-server'; +import type { SavedObjectUnsanitizedDoc } from '@kbn/core-saved-objects-server'; import { type IKibanaMigrator, type IndexMapping, type IndexTypesMap, + type ISavedObjectTypeRegistryInternal, type KibanaMigratorStatus, type MigrateDocumentOptions, type MigrationResult, @@ -45,7 +43,7 @@ import { runV2Migration } from './run_v2_migration'; export interface KibanaMigratorOptions { client: ElasticsearchClient; - typeRegistry: ISavedObjectTypeRegistry; + typeRegistry: ISavedObjectTypeRegistryInternal; defaultIndexTypesMap: IndexTypesMap; hashToVersionMap: Record; soMigrationsConfig: SavedObjectsMigrationConfigType; @@ -69,7 +67,7 @@ export class KibanaMigrator implements IKibanaMigrator { private readonly kibanaIndex: string; private readonly log: Logger; private readonly mappingProperties: SavedObjectsTypeMappingDefinitions; - private readonly typeRegistry: ISavedObjectTypeRegistry; + private readonly typeRegistry: ISavedObjectTypeRegistryInternal; private readonly defaultIndexTypesMap: IndexTypesMap; private readonly hashToVersionMap: Record; private readonly serializer: SavedObjectsSerializer; diff --git a/src/core/packages/saved-objects/server-internal/src/object_types/registration.ts b/src/core/packages/saved-objects/server-internal/src/object_types/registration.ts index 79e02f4288c4b..072b1a7625525 100644 --- a/src/core/packages/saved-objects/server-internal/src/object_types/registration.ts +++ b/src/core/packages/saved-objects/server-internal/src/object_types/registration.ts @@ -7,12 +7,12 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { ISavedObjectTypeRegistry, SavedObjectsType } from '@kbn/core-saved-objects-server'; -import type { SavedObjectTypeRegistry } from '@kbn/core-saved-objects-base-server-internal'; +import type { SavedObjectsType } from '@kbn/core-saved-objects-server'; import { LEGACY_URL_ALIAS_TYPE, type LegacyUrlAlias, } from '@kbn/core-saved-objects-base-server-internal'; +import type { ISavedObjectTypeRegistryInternal } from '@kbn/core-saved-objects-base-server-internal'; const legacyUrlAliasType: SavedObjectsType = { name: LEGACY_URL_ALIAS_TYPE, @@ -47,8 +47,6 @@ const legacyUrlAliasType: SavedObjectsType = { /** * @internal */ -export function registerCoreObjectTypes( - typeRegistry: ISavedObjectTypeRegistry & Pick -) { +export function registerCoreObjectTypes(typeRegistry: ISavedObjectTypeRegistryInternal) { typeRegistry.registerType(legacyUrlAliasType); } diff --git a/src/core/packages/saved-objects/server-internal/src/saved_objects_service.test.ts b/src/core/packages/saved-objects/server-internal/src/saved_objects_service.test.ts index 5b2ffa7be9dc0..06461ddfbe675 100644 --- a/src/core/packages/saved-objects/server-internal/src/saved_objects_service.test.ts +++ b/src/core/packages/saved-objects/server-internal/src/saved_objects_service.test.ts @@ -55,10 +55,24 @@ import { import { registerCoreObjectTypes } from './object_types'; import { getSavedObjectsDeprecationsProvider } from './deprecations'; +import type { SavedObjectsAccessControlTransforms } from '@kbn/core-saved-objects-server/src/contracts'; +import * as SavedObjectsImportExportModule from '@kbn/core-saved-objects-import-export-server-internal'; jest.mock('./object_types'); jest.mock('./deprecations'); +// Mock the importer and exporter constructors +jest.mock('@kbn/core-saved-objects-import-export-server-internal', () => { + return { + SavedObjectsExporter: jest.fn().mockImplementation(() => { + return {}; + }), + SavedObjectsImporter: jest.fn().mockImplementation(() => { + return {}; + }), + }; +}); + const createType = (parts: Partial): SavedObjectsType => ({ name: 'test-type', hidden: false, @@ -77,7 +91,8 @@ describe('SavedObjectsService', () => { const createCoreContext = ({ skipMigration = true, env, - }: { skipMigration?: boolean; env?: Env } = {}) => { + accessControlEnabled, + }: { skipMigration?: boolean; env?: Env; accessControlEnabled?: boolean } = {}) => { const configService = configServiceMock.create({ atPath: { skip: true } }); configService.atPath.mockImplementation((path) => { if (path === 'migrations') { @@ -86,6 +101,7 @@ describe('SavedObjectsService', () => { return new BehaviorSubject({ maxImportPayloadBytes: new ByteSizeValue(0), maxImportExportSize: 10000, + enableAccessControl: accessControlEnabled ?? false, }); }); return mockCoreContext.create({ configService, env }); @@ -388,696 +404,841 @@ describe('SavedObjectsService', () => { ); }); }); - }); - - describe('#start()', () => { - it('skips KibanaMigrator migrations when pluginsInitialized=false', async () => { - const coreContext = createCoreContext({ skipMigration: false }); - const soService = new SavedObjectsService(coreContext); - - await soService.setup(createSetupDeps()); - await soService.start(createStartDeps(false)); - expect(migratorInstanceMock.runMigrations).not.toHaveBeenCalled(); - }); - it('skips KibanaMigrator migrations when migrations.skip=true', async () => { - const coreContext = createCoreContext({ skipMigration: true }); - const soService = new SavedObjectsService(coreContext); - await soService.setup(createSetupDeps()); - await soService.start(createStartDeps()); - expect(migratorInstanceMock.runMigrations).not.toHaveBeenCalled(); - }); - - it('calls KibanaMigrator with correct version', async () => { - const pkg = loadJsonFile.sync(join(REPO_ROOT, 'package.json')) as RawPackageInfo; - const kibanaVersion = pkg.version; + describe('#isAccessControlEnabled', () => { + it('returns false by default', async () => { + const coreContext = createCoreContext({}); + const soService = new SavedObjectsService(coreContext); + const { isAccessControlEnabled } = await soService.setup(createSetupDeps()); + expect(isAccessControlEnabled()).toEqual(false); + }); - const coreContext = createCoreContext({ - env: Env.createDefault(REPO_ROOT, getEnvOptions(), { - ...pkg, - version: `${kibanaVersion}-beta1`, // test behavior when release has a version qualifier - }), + it('can be set to true', async () => { + const coreContext = createCoreContext({ accessControlEnabled: true }); + const soService = new SavedObjectsService(coreContext); + const { isAccessControlEnabled } = await soService.setup(createSetupDeps()); + expect(isAccessControlEnabled()).toEqual(true); }); - const soService = new SavedObjectsService(coreContext); - await soService.setup(createSetupDeps()); - await soService.start(createStartDeps()); + describe('#setAccessControlTransforms', () => { + it('sets the access control transforms', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + const setup = await soService.setup(createSetupDeps()); - expect(KibanaMigratorMock).toHaveBeenCalledWith(expect.objectContaining({ kibanaVersion })); - }); + const accessControlTransforms: SavedObjectsAccessControlTransforms = { + createImportTransforms: jest.fn(), + }; - it('calls KibanaMigrator with waitForMigrationCompletion=false for the default ui+background tasks role', async () => { - const pkg = loadJsonFile.sync(join(REPO_ROOT, 'package.json')) as RawPackageInfo; - const kibanaVersion = pkg.version; + setup.setAccessControlTransforms(accessControlTransforms); - const coreContext = createCoreContext({ - env: Env.createDefault(REPO_ROOT, getEnvOptions(), { - ...pkg, - version: `${kibanaVersion}-beta1`, // test behavior when release has a version qualifier - }), - }); + const request = httpServerMock.createKibanaRequest(); + const start = await soService.start(createStartDeps()); + start.createImporter(start.getScopedClient(request)); - const soService = new SavedObjectsService(coreContext); - await soService.setup(createSetupDeps()); - const startDeps = createStartDeps(); - startDeps.node = nodeServiceMock.createInternalStartContract({ - ui: true, - backgroundTasks: true, - migrator: false, - }); - await soService.start(startDeps); + expect(SavedObjectsImportExportModule.SavedObjectsImporter).toHaveBeenCalledWith( + expect.objectContaining({ + createAccessControlImportTransforms: accessControlTransforms.createImportTransforms, + }) + ); + }); - expect(KibanaMigratorMock).toHaveBeenCalledWith( - expect.objectContaining({ waitForMigrationCompletion: false }) - ); + it('throws if a factory is already registered', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + const setup = await soService.setup(createSetupDeps()); + + const accessControlTransforms: SavedObjectsAccessControlTransforms = { + // exportTransform: jest.fn(), + createImportTransforms: jest.fn(), + }; + + setup.setAccessControlTransforms(accessControlTransforms); + expect(() => { + setup.setAccessControlTransforms(accessControlTransforms); + }).toThrowErrorMatchingInlineSnapshot( + `"access control tranforms have already been set, and can only be set once"` + ); + }); + }); }); - it('calls KibanaMigrator with waitForMigrationCompletion=false for the ui only role', async () => { - const pkg = loadJsonFile.sync(join(REPO_ROOT, 'package.json')) as RawPackageInfo; - const kibanaVersion = pkg.version; + describe('#start()', () => { + it('skips KibanaMigrator migrations when pluginsInitialized=false', async () => { + const coreContext = createCoreContext({ skipMigration: false }); + const soService = new SavedObjectsService(coreContext); - const coreContext = createCoreContext({ - env: Env.createDefault(REPO_ROOT, getEnvOptions(), { - ...pkg, - version: `${kibanaVersion}-beta1`, // test behavior when release has a version qualifier - }), + await soService.setup(createSetupDeps()); + await soService.start(createStartDeps(false)); + expect(migratorInstanceMock.runMigrations).not.toHaveBeenCalled(); }); - const soService = new SavedObjectsService(coreContext); - await soService.setup(createSetupDeps()); - const startDeps = createStartDeps(); - startDeps.node = nodeServiceMock.createInternalStartContract({ - ui: true, - backgroundTasks: false, - migrator: false, + it('skips KibanaMigrator migrations when migrations.skip=true', async () => { + const coreContext = createCoreContext({ skipMigration: true }); + const soService = new SavedObjectsService(coreContext); + await soService.setup(createSetupDeps()); + await soService.start(createStartDeps()); + expect(migratorInstanceMock.runMigrations).not.toHaveBeenCalled(); }); - await soService.start(startDeps); - expect(KibanaMigratorMock).toHaveBeenCalledWith( - expect.objectContaining({ waitForMigrationCompletion: false }) - ); - }); + it('calls KibanaMigrator with correct version', async () => { + const pkg = loadJsonFile.sync(join(REPO_ROOT, 'package.json')) as RawPackageInfo; + const kibanaVersion = pkg.version; - it('calls KibanaMigrator with waitForMigrationCompletion=true for the background tasks only role', async () => { - const pkg = loadJsonFile.sync(join(REPO_ROOT, 'package.json')) as RawPackageInfo; - const kibanaVersion = pkg.version; + const coreContext = createCoreContext({ + env: Env.createDefault(REPO_ROOT, getEnvOptions(), { + ...pkg, + version: `${kibanaVersion}-beta1`, // test behavior when release has a version qualifier + }), + }); - const coreContext = createCoreContext({ - env: Env.createDefault(REPO_ROOT, getEnvOptions(), { - ...pkg, - version: `${kibanaVersion}-beta1`, // test behavior when release has a version qualifier - }), - }); + const soService = new SavedObjectsService(coreContext); + await soService.setup(createSetupDeps()); + await soService.start(createStartDeps()); - const soService = new SavedObjectsService(coreContext); - await soService.setup(createSetupDeps()); - const startDeps = createStartDeps(); - startDeps.node = nodeServiceMock.createInternalStartContract({ - ui: false, - backgroundTasks: true, - migrator: false, + expect(KibanaMigratorMock).toHaveBeenCalledWith(expect.objectContaining({ kibanaVersion })); }); - await soService.start(startDeps); - expect(KibanaMigratorMock).toHaveBeenCalledWith( - expect.objectContaining({ waitForMigrationCompletion: true }) - ); - }); + it('calls KibanaMigrator with waitForMigrationCompletion=false for the default ui+background tasks role', async () => { + const pkg = loadJsonFile.sync(join(REPO_ROOT, 'package.json')) as RawPackageInfo; + const kibanaVersion = pkg.version; - it('waits for all es nodes to be compatible before running migrations', async () => { - expect.assertions(2); - const coreContext = createCoreContext({ skipMigration: false }); - const soService = new SavedObjectsService(coreContext); - const setupDeps = createSetupDeps(); - // Create an new subject so that we can control when isCompatible=true - // is emitted. - setupDeps.elasticsearch.esNodesCompatibility$ = new BehaviorSubject({ - isCompatible: false, - incompatibleNodes: [], - warningNodes: [], - kibanaVersion: '8.0.0', - }); - await soService.setup(setupDeps); - soService.start(createStartDeps()); - expect(migratorInstanceMock.runMigrations).toHaveBeenCalledTimes(0); - ( - setupDeps.elasticsearch - .esNodesCompatibility$ as any as BehaviorSubject - ).next({ - isCompatible: true, - incompatibleNodes: [], - warningNodes: [], - kibanaVersion: '8.0.0', - }); + const coreContext = createCoreContext({ + env: Env.createDefault(REPO_ROOT, getEnvOptions(), { + ...pkg, + version: `${kibanaVersion}-beta1`, // test behavior when release has a version qualifier + }), + }); - await setImmediate(); - expect(migratorInstanceMock.runMigrations).toHaveBeenCalledTimes(1); - }); + const soService = new SavedObjectsService(coreContext); + await soService.setup(createSetupDeps()); + const startDeps = createStartDeps(); + startDeps.node = nodeServiceMock.createInternalStartContract({ + ui: true, + backgroundTasks: true, + migrator: false, + }); + await soService.start(startDeps); - it('does not start the migration if esNodesCompatibility$ is closed before calling `start`', async () => { - expect.assertions(2); - const coreContext = createCoreContext({ skipMigration: false }); - const soService = new SavedObjectsService(coreContext); - const setupDeps = createSetupDeps(); - // Create an new subject so that we can control when isCompatible=true - // is emitted. - setupDeps.elasticsearch.esNodesCompatibility$ = EMPTY; - await soService.setup(setupDeps); - await expect(() => - soService.start(createStartDeps()) - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"esNodesCompatibility$ was closed before emitting"` - ); + expect(KibanaMigratorMock).toHaveBeenCalledWith( + expect.objectContaining({ waitForMigrationCompletion: false }) + ); + }); - expect(migratorInstanceMock.runMigrations).not.toHaveBeenCalled(); - }); + it('calls KibanaMigrator with waitForMigrationCompletion=false for the ui only role', async () => { + const pkg = loadJsonFile.sync(join(REPO_ROOT, 'package.json')) as RawPackageInfo; + const kibanaVersion = pkg.version; - it('resolves with KibanaMigrator after waiting for migrations to complete', async () => { - const coreContext = createCoreContext({ skipMigration: false }); - const soService = new SavedObjectsService(coreContext); - await soService.setup(createSetupDeps()); - expect(migratorInstanceMock.runMigrations).toHaveBeenCalledTimes(0); + const coreContext = createCoreContext({ + env: Env.createDefault(REPO_ROOT, getEnvOptions(), { + ...pkg, + version: `${kibanaVersion}-beta1`, // test behavior when release has a version qualifier + }), + }); - await soService.start(createStartDeps()); - expect(migratorInstanceMock.runMigrations).toHaveBeenCalledTimes(1); - }); + const soService = new SavedObjectsService(coreContext); + await soService.setup(createSetupDeps()); + const startDeps = createStartDeps(); + startDeps.node = nodeServiceMock.createInternalStartContract({ + ui: true, + backgroundTasks: false, + migrator: false, + }); + await soService.start(startDeps); - it('throws when calling setup APIs once started', async () => { - const coreContext = createCoreContext({ skipMigration: false }); - const soService = new SavedObjectsService(coreContext); - const setup = await soService.setup(createSetupDeps()); - await soService.start(createStartDeps()); + expect(KibanaMigratorMock).toHaveBeenCalledWith( + expect.objectContaining({ waitForMigrationCompletion: false }) + ); + }); - expect(() => { - setup.setClientFactoryProvider(jest.fn()); - }).toThrowErrorMatchingInlineSnapshot( - `"cannot call \`setClientFactoryProvider\` after service startup."` - ); + it('calls KibanaMigrator with waitForMigrationCompletion=true for the background tasks only role', async () => { + const pkg = loadJsonFile.sync(join(REPO_ROOT, 'package.json')) as RawPackageInfo; + const kibanaVersion = pkg.version; - expect(() => { - setup.registerType({ - name: 'someType', - hidden: false, - namespaceType: 'single' as 'single', - mappings: { properties: {} }, + const coreContext = createCoreContext({ + env: Env.createDefault(REPO_ROOT, getEnvOptions(), { + ...pkg, + version: `${kibanaVersion}-beta1`, // test behavior when release has a version qualifier + }), }); - }).toThrowErrorMatchingInlineSnapshot( - `"cannot call \`registerType\` after service startup."` - ); - }); - it('returns the information about the time spent migrating', async () => { - const coreContext = createCoreContext({ skipMigration: false }); - const soService = new SavedObjectsService(coreContext); + const soService = new SavedObjectsService(coreContext); + await soService.setup(createSetupDeps()); + const startDeps = createStartDeps(); + startDeps.node = nodeServiceMock.createInternalStartContract({ + ui: false, + backgroundTasks: true, + migrator: false, + }); + await soService.start(startDeps); - migratorInstanceMock.runMigrations.mockImplementation(async () => { - await new Promise((r) => setTimeout(r, 5)); - return []; + expect(KibanaMigratorMock).toHaveBeenCalledWith( + expect.objectContaining({ waitForMigrationCompletion: true }) + ); }); - await soService.setup(createSetupDeps()); - const startContract = await soService.start(createStartDeps()); - - expect(startContract.metrics.migrationDuration).toBeGreaterThan(0); - }); - - describe('#getTypeRegistry', () => { - it('returns the internal type registry of the service', async () => { + it('waits for all es nodes to be compatible before running migrations', async () => { + expect.assertions(2); const coreContext = createCoreContext({ skipMigration: false }); const soService = new SavedObjectsService(coreContext); - await soService.setup(createSetupDeps()); - const { getTypeRegistry } = await soService.start(createStartDeps()); + const setupDeps = createSetupDeps(); + // Create an new subject so that we can control when isCompatible=true + // is emitted. + setupDeps.elasticsearch.esNodesCompatibility$ = new BehaviorSubject({ + isCompatible: false, + incompatibleNodes: [], + warningNodes: [], + kibanaVersion: '8.0.0', + }); + await soService.setup(setupDeps); + soService.start(createStartDeps()); + expect(migratorInstanceMock.runMigrations).toHaveBeenCalledTimes(0); + ( + setupDeps.elasticsearch + .esNodesCompatibility$ as any as BehaviorSubject + ).next({ + isCompatible: true, + incompatibleNodes: [], + warningNodes: [], + kibanaVersion: '8.0.0', + }); - expect(getTypeRegistry()).toBe(typeRegistryInstanceMock); + await setImmediate(); + expect(migratorInstanceMock.runMigrations).toHaveBeenCalledTimes(1); }); - }); - describe('#createScopedRepository', () => { - it('creates a repository scoped to the user', async () => { + it('does not start the migration if esNodesCompatibility$ is closed before calling `start`', async () => { + expect.assertions(2); const coreContext = createCoreContext({ skipMigration: false }); const soService = new SavedObjectsService(coreContext); - const coreSetup = createSetupDeps(); - await soService.setup(coreSetup); - const coreStart = createStartDeps(); - const { createScopedRepository } = await soService.start(coreStart); - - const req = httpServerMock.createKibanaRequest(); - createScopedRepository(req); + const setupDeps = createSetupDeps(); + // Create an new subject so that we can control when isCompatible=true + // is emitted. + setupDeps.elasticsearch.esNodesCompatibility$ = EMPTY; + await soService.setup(setupDeps); + await expect(() => + soService.start(createStartDeps()) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"esNodesCompatibility$ was closed before emitting"` + ); - expect(coreStart.elasticsearch.client.asScoped).toHaveBeenCalledWith(req); + expect(migratorInstanceMock.runMigrations).not.toHaveBeenCalled(); + }); - const [[, , , , , includedHiddenTypes]] = ( - SavedObjectsRepository.createRepository as jest.Mocked - ).mock.calls; + it('resolves with KibanaMigrator after waiting for migrations to complete', async () => { + const coreContext = createCoreContext({ skipMigration: false }); + const soService = new SavedObjectsService(coreContext); + await soService.setup(createSetupDeps()); + expect(migratorInstanceMock.runMigrations).toHaveBeenCalledTimes(0); - expect(includedHiddenTypes).toEqual([]); + await soService.start(createStartDeps()); + expect(migratorInstanceMock.runMigrations).toHaveBeenCalledTimes(1); }); - it('creates a repository including hidden types when specified', async () => { + it('throws when calling setup APIs once started', async () => { const coreContext = createCoreContext({ skipMigration: false }); const soService = new SavedObjectsService(coreContext); - const coreSetup = createSetupDeps(); - await soService.setup(coreSetup); - const coreStart = createStartDeps(); - const { createScopedRepository } = await soService.start(coreStart); - - const req = httpServerMock.createKibanaRequest(); - createScopedRepository(req, ['someHiddenType']); + const setup = await soService.setup(createSetupDeps()); + await soService.start(createStartDeps()); - const [[, , , , , includedHiddenTypes]] = ( - SavedObjectsRepository.createRepository as jest.Mocked - ).mock.calls; + expect(() => { + setup.setClientFactoryProvider(jest.fn()); + }).toThrowErrorMatchingInlineSnapshot( + `"cannot call \`setClientFactoryProvider\` after service startup."` + ); - expect(includedHiddenTypes).toEqual(['someHiddenType']); + expect(() => { + setup.registerType({ + name: 'someType', + hidden: false, + namespaceType: 'single' as 'single', + mappings: { properties: {} }, + }); + }).toThrowErrorMatchingInlineSnapshot( + `"cannot call \`registerType\` after service startup."` + ); }); - }); - describe('#createInternalRepository', () => { - it('creates a repository using the admin user', async () => { + it('returns the information about the time spent migrating', async () => { const coreContext = createCoreContext({ skipMigration: false }); const soService = new SavedObjectsService(coreContext); - const coreSetup = createSetupDeps(); - await soService.setup(coreSetup); - const coreStart = createStartDeps(); - const { createInternalRepository } = await soService.start(coreStart); - createInternalRepository(); + migratorInstanceMock.runMigrations.mockImplementation(async () => { + await new Promise((r) => setTimeout(r, 5)); + return []; + }); - const [[, , , client, , includedHiddenTypes]] = ( - SavedObjectsRepository.createRepository as jest.Mocked - ).mock.calls; + await soService.setup(createSetupDeps()); + const startContract = await soService.start(createStartDeps()); - expect(coreStart.elasticsearch.client.asInternalUser).toBe(client); - expect(includedHiddenTypes).toEqual([]); + expect(startContract.metrics.migrationDuration).toBeGreaterThan(0); }); - it('creates a repository including hidden types when specified', async () => { - const coreContext = createCoreContext({ skipMigration: false }); - const soService = new SavedObjectsService(coreContext); - const coreSetup = createSetupDeps(); - await soService.setup(coreSetup); - const { createInternalRepository } = await soService.start(createStartDeps()); + describe('#getTypeRegistry', () => { + it('returns the internal type registry of the service', async () => { + const coreContext = createCoreContext({ skipMigration: false }); + const soService = new SavedObjectsService(coreContext); + await soService.setup(createSetupDeps()); + const { getTypeRegistry } = await soService.start(createStartDeps()); - createInternalRepository(['someHiddenType']); + expect(getTypeRegistry()).toBe(typeRegistryInstanceMock); + }); + }); - const [[, , , , , includedHiddenTypes]] = ( - SavedObjectsRepository.createRepository as jest.Mocked - ).mock.calls; + describe('#createScopedRepository', () => { + it('creates a repository scoped to the user', async () => { + const coreContext = createCoreContext({ skipMigration: false }); + const soService = new SavedObjectsService(coreContext); + const coreSetup = createSetupDeps(); + await soService.setup(coreSetup); + const coreStart = createStartDeps(); + const { createScopedRepository } = await soService.start(coreStart); - expect(includedHiddenTypes).toEqual(['someHiddenType']); - }); - }); + const req = httpServerMock.createKibanaRequest(); + createScopedRepository(req); - describe('index retrieval APIs', () => { - let soService: SavedObjectsService; + expect(coreStart.elasticsearch.client.asScoped).toHaveBeenCalledWith(req); - beforeEach(async () => { - const coreContext = createCoreContext({ skipMigration: false }); - soService = new SavedObjectsService(coreContext); + const [[, , , , , includedHiddenTypes]] = ( + SavedObjectsRepository.createRepository as jest.Mocked + ).mock.calls; - typeRegistryInstanceMock.getType.mockImplementation((type: string) => { - if (type === 'dashboard') { - return createType({ - name: 'dashboard', - }); - } else if (type === 'foo') { - return createType({ - name: 'foo', - indexPattern: '.kibana_foo', - }); - } else if (type === 'bar') { - return createType({ - name: 'bar', - indexPattern: '.kibana_bar', - }); - } else if (type === 'bar_too') { - return createType({ - name: 'bar_too', - indexPattern: '.kibana_bar', - }); - } else { - return undefined; - } + expect(includedHiddenTypes).toEqual([]); }); - await soService.setup(createSetupDeps()); - }); + it('creates a repository including hidden types when specified', async () => { + const coreContext = createCoreContext({ skipMigration: false }); + const soService = new SavedObjectsService(coreContext); + const coreSetup = createSetupDeps(); + await soService.setup(coreSetup); + const coreStart = createStartDeps(); + const { createScopedRepository } = await soService.start(coreStart); + + const req = httpServerMock.createKibanaRequest(); + createScopedRepository(req, ['someHiddenType']); + + const [[, , , , , includedHiddenTypes]] = ( + SavedObjectsRepository.createRepository as jest.Mocked + ).mock.calls; - describe('#getDefaultIndex', () => { - it('return the default index', async () => { - const { getDefaultIndex } = await soService.start(createStartDeps()); - expect(getDefaultIndex()).toEqual(MAIN_SAVED_OBJECT_INDEX); + expect(includedHiddenTypes).toEqual(['someHiddenType']); }); }); - describe('#getIndexForType', () => { - it('return the correct index for type specifying its indexPattern', async () => { - const { getIndexForType } = await soService.start(createStartDeps()); - expect(getIndexForType('bar')).toEqual('.kibana_bar'); - }); - it('return the correct index for type not specifying its indexPattern', async () => { - const { getIndexForType } = await soService.start(createStartDeps()); - expect(getIndexForType('dashboard')).toEqual(MAIN_SAVED_OBJECT_INDEX); + describe('#createInternalRepository', () => { + it('creates a repository using the admin user', async () => { + const coreContext = createCoreContext({ skipMigration: false }); + const soService = new SavedObjectsService(coreContext); + const coreSetup = createSetupDeps(); + await soService.setup(coreSetup); + const coreStart = createStartDeps(); + const { createInternalRepository } = await soService.start(coreStart); + + createInternalRepository(); + + const [[, , , client, , includedHiddenTypes]] = ( + SavedObjectsRepository.createRepository as jest.Mocked + ).mock.calls; + + expect(coreStart.elasticsearch.client.asInternalUser).toBe(client); + expect(includedHiddenTypes).toEqual([]); }); - it('return the default index for unknown type', async () => { - const { getIndexForType } = await soService.start(createStartDeps()); - expect(getIndexForType('unknown_type')).toEqual(MAIN_SAVED_OBJECT_INDEX); + + it('creates a repository including hidden types when specified', async () => { + const coreContext = createCoreContext({ skipMigration: false }); + const soService = new SavedObjectsService(coreContext); + const coreSetup = createSetupDeps(); + await soService.setup(coreSetup); + const { createInternalRepository } = await soService.start(createStartDeps()); + + createInternalRepository(['someHiddenType']); + + const [[, , , , , includedHiddenTypes]] = ( + SavedObjectsRepository.createRepository as jest.Mocked + ).mock.calls; + + expect(includedHiddenTypes).toEqual(['someHiddenType']); }); }); - describe('#getIndicesForTypes', () => { - it('return the correct indices for specified types', async () => { - const { getIndicesForTypes } = await soService.start(createStartDeps()); - expect(getIndicesForTypes(['dashboard', 'foo', 'bar'])).toEqual([ - MAIN_SAVED_OBJECT_INDEX, - '.kibana_foo', - '.kibana_bar', - ]); - }); - it('ignore duplicate indices', async () => { - const { getIndicesForTypes } = await soService.start(createStartDeps()); - expect(getIndicesForTypes(['bar', 'bar_too'])).toEqual(['.kibana_bar']); - }); - it('return the default index for unknown type', async () => { - const { getIndicesForTypes } = await soService.start(createStartDeps()); - expect(getIndicesForTypes(['unknown', 'foo'])).toEqual([ - MAIN_SAVED_OBJECT_INDEX, - '.kibana_foo', - ]); + describe('index retrieval APIs', () => { + let soService: SavedObjectsService; + + beforeEach(async () => { + const coreContext = createCoreContext({ skipMigration: false }); + soService = new SavedObjectsService(coreContext); + + typeRegistryInstanceMock.getType.mockImplementation((type: string) => { + if (type === 'dashboard') { + return createType({ + name: 'dashboard', + }); + } else if (type === 'foo') { + return createType({ + name: 'foo', + indexPattern: '.kibana_foo', + }); + } else if (type === 'bar') { + return createType({ + name: 'bar', + indexPattern: '.kibana_bar', + }); + } else if (type === 'bar_too') { + return createType({ + name: 'bar_too', + indexPattern: '.kibana_bar', + }); + } else { + return undefined; + } + }); + + await soService.setup(createSetupDeps()); }); - }); - }); - describe('#getUnsafeInternalClient', () => { - it('returns a SavedObjectsClient instance', async () => { - const coreContext = createCoreContext(); - const soService = new SavedObjectsService(coreContext); - await soService.setup(createSetupDeps()); - const { getUnsafeInternalClient } = await soService.start(createStartDeps()); + describe('#getDefaultIndex', () => { + it('return the default index', async () => { + const { getDefaultIndex } = await soService.start(createStartDeps()); + expect(getDefaultIndex()).toEqual(MAIN_SAVED_OBJECT_INDEX); + }); + }); - const client = getUnsafeInternalClient(); + describe('#getIndexForType', () => { + it('return the correct index for type specifying its indexPattern', async () => { + const { getIndexForType } = await soService.start(createStartDeps()); + expect(getIndexForType('bar')).toEqual('.kibana_bar'); + }); + it('return the correct index for type not specifying its indexPattern', async () => { + const { getIndexForType } = await soService.start(createStartDeps()); + expect(getIndexForType('dashboard')).toEqual(MAIN_SAVED_OBJECT_INDEX); + }); + it('return the default index for unknown type', async () => { + const { getIndexForType } = await soService.start(createStartDeps()); + expect(getIndexForType('unknown_type')).toEqual(MAIN_SAVED_OBJECT_INDEX); + }); + }); - expect(client).toBeInstanceOf(SavedObjectsClient); + describe('#getIndicesForTypes', () => { + it('return the correct indices for specified types', async () => { + const { getIndicesForTypes } = await soService.start(createStartDeps()); + expect(getIndicesForTypes(['dashboard', 'foo', 'bar'])).toEqual([ + MAIN_SAVED_OBJECT_INDEX, + '.kibana_foo', + '.kibana_bar', + ]); + }); + it('ignore duplicate indices', async () => { + const { getIndicesForTypes } = await soService.start(createStartDeps()); + expect(getIndicesForTypes(['bar', 'bar_too'])).toEqual(['.kibana_bar']); + }); + it('return the default index for unknown type', async () => { + const { getIndicesForTypes } = await soService.start(createStartDeps()); + expect(getIndicesForTypes(['unknown', 'foo'])).toEqual([ + MAIN_SAVED_OBJECT_INDEX, + '.kibana_foo', + ]); + }); + }); }); - it('is bound correctly in start contract', async () => { - const coreContext = createCoreContext(); - const soService = new SavedObjectsService(coreContext); - await soService.setup(createSetupDeps()); - const startContract = await soService.start(createStartDeps()); + describe('#getUnsafeInternalClient', () => { + it('returns a SavedObjectsClient instance', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + await soService.setup(createSetupDeps()); + const { getUnsafeInternalClient } = await soService.start(createStartDeps()); - expect(startContract).toHaveProperty('getUnsafeInternalClient'); - expect(typeof startContract.getUnsafeInternalClient).toBe('function'); - }); + const client = getUnsafeInternalClient(); - it('works with no options parameter (default behavior)', async () => { - const coreContext = createCoreContext(); - const soService = new SavedObjectsService(coreContext); - await soService.setup(createSetupDeps()); - const { getUnsafeInternalClient } = await soService.start(createStartDeps()); + expect(client).toBeInstanceOf(SavedObjectsClient); + }); - expect(() => getUnsafeInternalClient()).not.toThrow(); - const client = getUnsafeInternalClient(); - expect(client).toBeInstanceOf(SavedObjectsClient); - }); + it('is bound correctly in start contract', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + await soService.setup(createSetupDeps()); + const startContract = await soService.start(createStartDeps()); - it('works with empty options object', async () => { - const coreContext = createCoreContext(); - const soService = new SavedObjectsService(coreContext); - await soService.setup(createSetupDeps()); - const { getUnsafeInternalClient } = await soService.start(createStartDeps()); + expect(startContract).toHaveProperty('getUnsafeInternalClient'); + expect(typeof startContract.getUnsafeInternalClient).toBe('function'); + }); - expect(() => getUnsafeInternalClient({})).not.toThrow(); - const client = getUnsafeInternalClient({}); - expect(client).toBeInstanceOf(SavedObjectsClient); - }); + it('works with no options parameter (default behavior)', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + await soService.setup(createSetupDeps()); + const { getUnsafeInternalClient } = await soService.start(createStartDeps()); - it('passes includedHiddenTypes to createInternalRepository', async () => { - const coreContext = createCoreContext(); - const soService = new SavedObjectsService(coreContext); - await soService.setup(createSetupDeps()); - const startContract = await soService.start(createStartDeps()); + expect(() => getUnsafeInternalClient()).not.toThrow(); + const client = getUnsafeInternalClient(); + expect(client).toBeInstanceOf(SavedObjectsClient); + }); - const includedHiddenTypes = ['hidden-type-1', 'hidden-type-2']; + it('works with empty options object', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + await soService.setup(createSetupDeps()); + const { getUnsafeInternalClient } = await soService.start(createStartDeps()); - // Test that the method accepts the parameter without throwing - expect(() => startContract.getUnsafeInternalClient({ includedHiddenTypes })).not.toThrow(); - const client = startContract.getUnsafeInternalClient({ includedHiddenTypes }); - expect(client).toBeInstanceOf(SavedObjectsClient); + expect(() => getUnsafeInternalClient({})).not.toThrow(); + const client = getUnsafeInternalClient({}); + expect(client).toBeInstanceOf(SavedObjectsClient); + }); - // Verify that SavedObjectsRepository.createRepository was called with the correct includedHiddenTypes - const calls = (SavedObjectsRepository.createRepository as jest.Mocked).mock.calls; - const [, , , , , lastCallIncludedHiddenTypes] = calls[calls.length - 1]; - expect(lastCallIncludedHiddenTypes).toEqual(includedHiddenTypes); - }); + it('passes includedHiddenTypes to createInternalRepository', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + await soService.setup(createSetupDeps()); + const startContract = await soService.start(createStartDeps()); + + const includedHiddenTypes = ['hidden-type-1', 'hidden-type-2']; + + // Test that the method accepts the parameter without throwing + expect(() => + startContract.getUnsafeInternalClient({ includedHiddenTypes }) + ).not.toThrow(); + const client = startContract.getUnsafeInternalClient({ includedHiddenTypes }); + expect(client).toBeInstanceOf(SavedObjectsClient); + + // Verify that SavedObjectsRepository.createRepository was called with the correct includedHiddenTypes + const calls = (SavedObjectsRepository.createRepository as jest.Mocked).mock.calls; + const [, , , , , lastCallIncludedHiddenTypes] = calls[calls.length - 1]; + expect(lastCallIncludedHiddenTypes).toEqual(includedHiddenTypes); + }); - it('passes excludedExtensions to getInternalExtensions', async () => { - const coreContext = createCoreContext(); - const soService = new SavedObjectsService(coreContext); + it('passes excludedExtensions to getInternalExtensions', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); - const getInternalExtensionsSpy = jest.spyOn(soService as any, 'getInternalExtensions'); + const getInternalExtensionsSpy = jest.spyOn(soService as any, 'getInternalExtensions'); - await soService.setup(createSetupDeps()); - const { getUnsafeInternalClient } = await soService.start(createStartDeps()); - const excludedExtensions = ['test-extension']; + await soService.setup(createSetupDeps()); + const { getUnsafeInternalClient } = await soService.start(createStartDeps()); + const excludedExtensions = ['test-extension']; - getUnsafeInternalClient({ excludedExtensions }); + getUnsafeInternalClient({ excludedExtensions }); - expect(getInternalExtensionsSpy).toHaveBeenCalledWith(excludedExtensions); - }); + expect(getInternalExtensionsSpy).toHaveBeenCalledWith(excludedExtensions); + }); - it('calls internal repository factory, not scoped repository factory', async () => { - const coreContext = createCoreContext(); - const soService = new SavedObjectsService(coreContext); - await soService.setup(createSetupDeps()); - const startContract = await soService.start(createStartDeps()); + it('calls internal repository factory, not scoped repository factory', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + await soService.setup(createSetupDeps()); + const startContract = await soService.start(createStartDeps()); - (SavedObjectsRepository.createRepository as jest.Mock).mockClear(); + (SavedObjectsRepository.createRepository as jest.Mock).mockClear(); - const client = startContract.getUnsafeInternalClient(); - expect(client).toBeInstanceOf(SavedObjectsClient); + const client = startContract.getUnsafeInternalClient(); + expect(client).toBeInstanceOf(SavedObjectsClient); - expect(SavedObjectsRepository.createRepository).toHaveBeenCalledTimes(1); - const [[, , , esClient]] = (SavedObjectsRepository.createRepository as jest.Mocked) - .mock.calls; + expect(SavedObjectsRepository.createRepository).toHaveBeenCalledTimes(1); + const [[, , , esClient]] = (SavedObjectsRepository.createRepository as jest.Mocked) + .mock.calls; - expect(esClient).toBeDefined(); - }); + expect(esClient).toBeDefined(); + }); - it('handles invalid options gracefully', async () => { - const coreContext = createCoreContext(); - const soService = new SavedObjectsService(coreContext); - await soService.setup(createSetupDeps()); - const { getUnsafeInternalClient } = await soService.start(createStartDeps()); + it('handles invalid options gracefully', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + await soService.setup(createSetupDeps()); + const { getUnsafeInternalClient } = await soService.start(createStartDeps()); - // Test with various invalid options - expect(() => getUnsafeInternalClient({ includedHiddenTypes: null as any })).not.toThrow(); - expect(() => getUnsafeInternalClient({ excludedExtensions: null as any })).not.toThrow(); - expect(() => getUnsafeInternalClient({ unknownOption: 'test' } as any)).not.toThrow(); + // Test with various invalid options + expect(() => getUnsafeInternalClient({ includedHiddenTypes: null as any })).not.toThrow(); + expect(() => getUnsafeInternalClient({ excludedExtensions: null as any })).not.toThrow(); + expect(() => getUnsafeInternalClient({ unknownOption: 'test' } as any)).not.toThrow(); + }); }); - }); - describe('#getInternalExtensions', () => { - it('automatically excludes security extension (always undefined)', async () => { - const coreContext = createCoreContext(); - const soService = new SavedObjectsService(coreContext); - const setup = await soService.setup(createSetupDeps()); + describe('#getInternalExtensions', () => { + it('automatically excludes security extension (always undefined)', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + const setup = await soService.setup(createSetupDeps()); - // Set up security extension factory - const securityFactory = jest.fn().mockReturnValue({}); - setup.setSecurityExtension(securityFactory); + // Set up security extension factory + const securityFactory = jest.fn().mockReturnValue({}); + setup.setSecurityExtension(securityFactory); - await soService.start(createStartDeps()); + await soService.start(createStartDeps()); - const extensions = (soService as any).getInternalExtensions([]); + const extensions = (soService as any).getInternalExtensions([]); - expect(extensions.securityExtension).toBeUndefined(); - expect(securityFactory).not.toHaveBeenCalled(); - }); + expect(extensions.securityExtension).toBeUndefined(); + expect(securityFactory).not.toHaveBeenCalled(); + }); - it('never includes security extension regardless of excludedExtensions parameter', async () => { - const coreContext = createCoreContext(); - const soService = new SavedObjectsService(coreContext); - const setup = await soService.setup(createSetupDeps()); + it('never includes security extension regardless of excludedExtensions parameter', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + const setup = await soService.setup(createSetupDeps()); - const securityFactory = jest.fn().mockReturnValue({}); - setup.setSecurityExtension(securityFactory); + const securityFactory = jest.fn().mockReturnValue({}); + setup.setSecurityExtension(securityFactory); - await soService.start(createStartDeps()); + await soService.start(createStartDeps()); - // Even if we don't explicitly exclude security, it should still be excluded - const extensions = (soService as any).getInternalExtensions([]); + // Even if we don't explicitly exclude security, it should still be excluded + const extensions = (soService as any).getInternalExtensions([]); - expect(extensions.securityExtension).toBeUndefined(); - expect(securityFactory).not.toHaveBeenCalled(); + expect(extensions.securityExtension).toBeUndefined(); + expect(securityFactory).not.toHaveBeenCalled(); - // Test with different combinations of excludedExtensions - const extensionsWithOthers = (soService as any).getInternalExtensions([ - 'someOtherExtension', - ]); - expect(extensionsWithOthers.securityExtension).toBeUndefined(); - expect(securityFactory).not.toHaveBeenCalled(); - }); + // Test with different combinations of excludedExtensions + const extensionsWithOthers = (soService as any).getInternalExtensions([ + 'someOtherExtension', + ]); + expect(extensionsWithOthers.securityExtension).toBeUndefined(); + expect(securityFactory).not.toHaveBeenCalled(); + }); - it('creates encryption extension when factory exists', async () => { - const coreContext = createCoreContext(); - const soService = new SavedObjectsService(coreContext); - const setup = await soService.setup(createSetupDeps()); + it('creates encryption extension when factory exists', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + const setup = await soService.setup(createSetupDeps()); - const mockEncryptionExtension = { id: 'encryption' }; - const encryptionFactory = jest.fn().mockReturnValue(mockEncryptionExtension); - setup.setEncryptionExtension(encryptionFactory); + const mockEncryptionExtension = { id: 'encryption' }; + const encryptionFactory = jest.fn().mockReturnValue(mockEncryptionExtension); + setup.setEncryptionExtension(encryptionFactory); - await soService.start(createStartDeps()); + await soService.start(createStartDeps()); - const extensions = (soService as any).getInternalExtensions([]); + const extensions = (soService as any).getInternalExtensions([]); - expect(extensions.encryptionExtension).toBe(mockEncryptionExtension); - expect(encryptionFactory).toHaveBeenCalledWith({ - typeRegistry: expect.any(Object), - request: expect.objectContaining({ - headers: {}, - getBasePath: expect.any(Function), - path: '/', - }), + expect(extensions.encryptionExtension).toBe(mockEncryptionExtension); + expect(encryptionFactory).toHaveBeenCalledWith({ + typeRegistry: expect.any(Object), + request: expect.objectContaining({ + headers: {}, + getBasePath: expect.any(Function), + path: '/', + }), + }); }); - }); - it('creates spaces extension when factory exists', async () => { - const coreContext = createCoreContext(); - const soService = new SavedObjectsService(coreContext); - const setup = await soService.setup(createSetupDeps()); + it('creates spaces extension when factory exists', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + const setup = await soService.setup(createSetupDeps()); - const mockSpacesExtension = { id: 'spaces' }; - const spacesFactory = jest.fn().mockReturnValue(mockSpacesExtension); - setup.setSpacesExtension(spacesFactory); + const mockSpacesExtension = { id: 'spaces' }; + const spacesFactory = jest.fn().mockReturnValue(mockSpacesExtension); + setup.setSpacesExtension(spacesFactory); - await soService.start(createStartDeps()); + await soService.start(createStartDeps()); - const extensions = (soService as any).getInternalExtensions([]); + const extensions = (soService as any).getInternalExtensions([]); - expect(extensions.spacesExtension).toBe(mockSpacesExtension); - expect(spacesFactory).toHaveBeenCalledWith({ - typeRegistry: expect.any(Object), - request: expect.objectContaining({ - headers: {}, - getBasePath: expect.any(Function), - path: '/', - }), + expect(extensions.spacesExtension).toBe(mockSpacesExtension); + expect(spacesFactory).toHaveBeenCalledWith({ + typeRegistry: expect.any(Object), + request: expect.objectContaining({ + headers: {}, + getBasePath: expect.any(Function), + path: '/', + }), + }); }); - }); - it('excludes extensions via excludedExtensions parameter', async () => { - const coreContext = createCoreContext(); - const soService = new SavedObjectsService(coreContext); - const setup = await soService.setup(createSetupDeps()); + it('excludes extensions via excludedExtensions parameter', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + const setup = await soService.setup(createSetupDeps()); - const encryptionFactory = jest.fn().mockReturnValue({ id: 'encryption' }); - const spacesFactory = jest.fn().mockReturnValue({ id: 'spaces' }); - setup.setEncryptionExtension(encryptionFactory); - setup.setSpacesExtension(spacesFactory); + const encryptionFactory = jest.fn().mockReturnValue({ id: 'encryption' }); + const spacesFactory = jest.fn().mockReturnValue({ id: 'spaces' }); + setup.setEncryptionExtension(encryptionFactory); + setup.setSpacesExtension(spacesFactory); - await soService.start(createStartDeps()); - const excludedExtensions = ['encryptedSavedObjects']; + await soService.start(createStartDeps()); + const excludedExtensions = ['encryptedSavedObjects']; - const extensions = (soService as any).getInternalExtensions(excludedExtensions); + const extensions = (soService as any).getInternalExtensions(excludedExtensions); - expect(extensions.encryptionExtension).toBeUndefined(); - expect(extensions.spacesExtension).toBeDefined(); - expect(encryptionFactory).not.toHaveBeenCalled(); - expect(spacesFactory).toHaveBeenCalled(); - }); + expect(extensions.encryptionExtension).toBeUndefined(); + expect(extensions.spacesExtension).toBeDefined(); + expect(encryptionFactory).not.toHaveBeenCalled(); + expect(spacesFactory).toHaveBeenCalled(); + }); - it('handles undefined extension factories gracefully', async () => { - const coreContext = createCoreContext(); - const soService = new SavedObjectsService(coreContext); - await soService.setup(createSetupDeps()); - await soService.start(createStartDeps()); + it('handles undefined extension factories gracefully', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + await soService.setup(createSetupDeps()); + await soService.start(createStartDeps()); - // No extension factories set, should not throw - expect(() => (soService as any).getInternalExtensions([])).not.toThrow(); + // No extension factories set, should not throw + expect(() => (soService as any).getInternalExtensions([])).not.toThrow(); - const extensions = (soService as any).getInternalExtensions([]); + const extensions = (soService as any).getInternalExtensions([]); - expect(extensions.encryptionExtension).toBeUndefined(); - expect(extensions.spacesExtension).toBeUndefined(); - expect(extensions.securityExtension).toBeUndefined(); - }); + expect(extensions.encryptionExtension).toBeUndefined(); + expect(extensions.spacesExtension).toBeUndefined(); + expect(extensions.securityExtension).toBeUndefined(); + }); - it('creates proper fake request object for extension factories', async () => { - const coreContext = createCoreContext(); - const soService = new SavedObjectsService(coreContext); - const setup = await soService.setup(createSetupDeps()); + it('creates proper fake request object for extension factories', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + const setup = await soService.setup(createSetupDeps()); - const encryptionFactory = jest.fn().mockReturnValue({}); - setup.setEncryptionExtension(encryptionFactory); + const encryptionFactory = jest.fn().mockReturnValue({}); + setup.setEncryptionExtension(encryptionFactory); - await soService.start(createStartDeps()); + await soService.start(createStartDeps()); - (soService as any).getInternalExtensions([]); - - expect(encryptionFactory).toHaveBeenCalledWith({ - typeRegistry: expect.any(Object), - request: expect.objectContaining({ - headers: {}, - getBasePath: expect.any(Function), - path: '/', - route: { settings: {} }, - url: { href: '/' }, - raw: { req: { url: '/' } }, - }), + (soService as any).getInternalExtensions([]); + + expect(encryptionFactory).toHaveBeenCalledWith({ + typeRegistry: expect.any(Object), + request: expect.objectContaining({ + headers: {}, + getBasePath: expect.any(Function), + path: '/', + route: { settings: {} }, + url: { href: '/' }, + raw: { req: { url: '/' } }, + }), + }); + + // Test that getBasePath function works + const requestArg = encryptionFactory.mock.calls[0][0].request; + expect(requestArg.getBasePath()).toBe(''); }); - // Test that getBasePath function works - const requestArg = encryptionFactory.mock.calls[0][0].request; - expect(requestArg.getBasePath()).toBe(''); - }); + it('passes type registry correctly to extension factories', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + const setup = await soService.setup(createSetupDeps()); - it('passes type registry correctly to extension factories', async () => { - const coreContext = createCoreContext(); - const soService = new SavedObjectsService(coreContext); - const setup = await soService.setup(createSetupDeps()); + const encryptionFactory = jest.fn().mockReturnValue({}); + setup.setEncryptionExtension(encryptionFactory); - const encryptionFactory = jest.fn().mockReturnValue({}); - setup.setEncryptionExtension(encryptionFactory); + await soService.start(createStartDeps()); - await soService.start(createStartDeps()); + (soService as any).getInternalExtensions([]); - (soService as any).getInternalExtensions([]); + expect(encryptionFactory).toHaveBeenCalledWith({ + typeRegistry: setup.getTypeRegistry(), + request: expect.any(Object), + }); + }); - expect(encryptionFactory).toHaveBeenCalledWith({ - typeRegistry: setup.getTypeRegistry(), - request: expect.any(Object), + it('constructs finalExcludedExtensions array correctly', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + await soService.setup(createSetupDeps()); + + // Mock the createExt function to spy on excluded extensions check + const originalGetInternalExtensions = (soService as any).getInternalExtensions.bind( + soService + ); + let capturedExcludedExtensions: string[] = []; + + jest + .spyOn(soService as any, 'getInternalExtensions') + .mockImplementation((excludedExtensions = []) => { + // Capture the excluded extensions for verification + capturedExcludedExtensions = [ + ...(excludedExtensions as string[]), + 'securityExtension', + ]; + return originalGetInternalExtensions(excludedExtensions); + }); + + await soService.start(createStartDeps()); + + (soService as any).getInternalExtensions(['customExtension']); + + // Security extension should always be included in excluded extensions + expect(capturedExcludedExtensions.sort()).toEqual( + ['customExtension', 'securityExtension'].sort() + ); }); }); - it('constructs finalExcludedExtensions array correctly', async () => { - const coreContext = createCoreContext(); - const soService = new SavedObjectsService(coreContext); - await soService.setup(createSetupDeps()); - - // Mock the createExt function to spy on excluded extensions check - const originalGetInternalExtensions = (soService as any).getInternalExtensions.bind( - soService - ); - let capturedExcludedExtensions: string[] = []; - - jest - .spyOn(soService as any, 'getInternalExtensions') - .mockImplementation((excludedExtensions = []) => { - // Capture the excluded extensions for verification - capturedExcludedExtensions = [...(excludedExtensions as string[]), 'securityExtension']; - return originalGetInternalExtensions(excludedExtensions); - }); + // describe('#createExporter', () => { + // it(`calls the 'SavedObjectsExporter' constructor with export transform if set`, async () => { + // const coreContext = createCoreContext(); + // const soService = new SavedObjectsService(coreContext); + // const setup = await soService.setup(createSetupDeps()); + + // const accessControlTransforms: SavedObjectsAccessControlTransforms = { + // // exportTransform: jest.fn(), + // createImportTransforms: jest.fn(), + // }; + + // setup.setAccessControlTransforms(accessControlTransforms); + + // const request = httpServerMock.createKibanaRequest(); + // const start = await soService.start(createStartDeps()); + // start.createExporter(start.getScopedClient(request)); + + // // expect(SavedObjectsImportExportModule.SavedObjectsExporter).toHaveBeenCalledWith( + // // expect.objectContaining({ + // // accessControlExportTransform: accessControlTransforms.exportTransform, + // // }) + // // ); + // }); + + // it(`call the 'SavedObjectsExporter' constructor with undefined export transform if not set`, async () => { + // const coreContext = createCoreContext(); + // const soService = new SavedObjectsService(coreContext); + // await soService.setup(createSetupDeps()); + + // const request = httpServerMock.createKibanaRequest(); + // const start = await soService.start(createStartDeps()); + // start.createExporter(start.getScopedClient(request)); + + // expect(SavedObjectsImportExportModule.SavedObjectsExporter).toHaveBeenCalledWith( + // expect.objectContaining({ + // accessControlExportTransform: undefined, + // }) + // ); + // }); + // }); + + describe('#createImporter', () => { + it(`calls the 'SavedObjectsImporter' constructor with the 'createImportTransforms' function if set`, async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + const setup = await soService.setup(createSetupDeps()); + + const accessControlTransforms: SavedObjectsAccessControlTransforms = { + // exportTransform: jest.fn(), + createImportTransforms: jest.fn(), + }; + + setup.setAccessControlTransforms(accessControlTransforms); + + const request = httpServerMock.createKibanaRequest(); + const start = await soService.start(createStartDeps()); + start.createImporter(start.getScopedClient(request)); + + expect(SavedObjectsImportExportModule.SavedObjectsImporter).toHaveBeenCalledWith( + expect.objectContaining({ + createAccessControlImportTransforms: accessControlTransforms.createImportTransforms, + }) + ); + }); - await soService.start(createStartDeps()); + it(`call the 'SavedObjectsImporter' constructor with undefined 'createImportTransforms' function if not set`, async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + await soService.setup(createSetupDeps()); - (soService as any).getInternalExtensions(['customExtension']); + const request = httpServerMock.createKibanaRequest(); + const start = await soService.start(createStartDeps()); + start.createImporter(start.getScopedClient(request)); - // Security extension should always be included in excluded extensions - expect(capturedExcludedExtensions.sort()).toEqual( - ['customExtension', 'securityExtension'].sort() - ); + expect(SavedObjectsImportExportModule.SavedObjectsImporter).toHaveBeenCalledWith( + expect.objectContaining({ + createAccessControlImportTransforms: undefined, + }) + ); + }); }); }); }); diff --git a/src/core/packages/saved-objects/server-internal/src/saved_objects_service.ts b/src/core/packages/saved-objects/server-internal/src/saved_objects_service.ts index 7966aca5db791..9fa68712486dd 100644 --- a/src/core/packages/saved-objects/server-internal/src/saved_objects_service.ts +++ b/src/core/packages/saved-objects/server-internal/src/saved_objects_service.ts @@ -63,6 +63,7 @@ import type { InternalCoreUsageDataSetup } from '@kbn/core-usage-data-base-serve import type { DeprecationRegistryProvider } from '@kbn/core-deprecations-server'; import type { NodeInfo } from '@kbn/core-node-server'; import { MAIN_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; +import type { SavedObjectsAccessControlTransforms } from '@kbn/core-saved-objects-server/src/contracts'; import { registerRoutes } from './routes'; import { calculateStatus$ } from './status'; import { registerCoreObjectTypes } from './object_types'; @@ -124,9 +125,14 @@ export class SavedObjectsService private encryptionExtensionFactory?: SavedObjectsEncryptionExtensionFactory; private securityExtensionFactory?: SavedObjectsSecurityExtensionFactory; private spacesExtensionFactory?: SavedObjectsSpacesExtensionFactory; + private accessControlTransforms?: SavedObjectsAccessControlTransforms; private migrator$ = new Subject(); - private typeRegistry = new SavedObjectTypeRegistry({ legacyTypes: REMOVED_TYPES }); + + private typeRegistry = new SavedObjectTypeRegistry({ + legacyTypes: REMOVED_TYPES, + }); + private started = false; constructor(private readonly coreContext: CoreContext) { @@ -147,6 +153,9 @@ export class SavedObjectsService this.coreContext.configService.atPath('migrations') ); this.config = new SavedObjectConfig(savedObjectsConfig, savedObjectsMigrationConfig); + const accessControlEnabled = this.config.enableAccessControl; + this.typeRegistry.setAccessControlEnabled(accessControlEnabled); + deprecations.getRegistry('savedObjects').registerDeprecations( getSavedObjectsDeprecationsProvider({ kibanaIndex: MAIN_SAVED_OBJECT_INDEX, @@ -217,6 +226,17 @@ export class SavedObjectsService } this.spacesExtensionFactory = factory; }, + setAccessControlTransforms: (transforms) => { + if (this.started) { + throw new Error('cannot call `setAccessControlTransforms` after service startup.'); + } + if (this.accessControlTransforms) { + throw new Error( + 'access control tranforms have already been set, and can only be set once' + ); + } + this.accessControlTransforms = transforms; + }, registerType: (type) => { if (this.started) { throw new Error('cannot call `registerType` after service startup.'); @@ -225,6 +245,7 @@ export class SavedObjectsService }, getTypeRegistry: () => this.typeRegistry, getDefaultIndex: () => MAIN_SAVED_OBJECT_INDEX, + isAccessControlEnabled: () => accessControlEnabled, }; } @@ -391,6 +412,7 @@ export class SavedObjectsService typeRegistry: this.typeRegistry, importSizeLimit: options?.importSizeLimit ?? this.config!.maxImportExportSize, logger: this.logger.get('importer'), + createAccessControlImportTransforms: this.accessControlTransforms?.createImportTransforms, }), getTypeRegistry: () => this.typeRegistry, getDefaultIndex: () => MAIN_SAVED_OBJECT_INDEX, diff --git a/src/core/packages/saved-objects/server-mocks/src/saved_objects_service.mock.ts b/src/core/packages/saved-objects/server-mocks/src/saved_objects_service.mock.ts index 2f11edefa566c..579f6ac1a2bcf 100644 --- a/src/core/packages/saved-objects/server-mocks/src/saved_objects_service.mock.ts +++ b/src/core/packages/saved-objects/server-mocks/src/saved_objects_service.mock.ts @@ -71,8 +71,10 @@ const createSetupContractMock = () => { setEncryptionExtension: jest.fn(), setSecurityExtension: jest.fn(), setSpacesExtension: jest.fn(), + setAccessControlTransforms: jest.fn(), registerType: jest.fn(), getDefaultIndex: jest.fn().mockReturnValue(MAIN_SAVED_OBJECT_INDEX), + isAccessControlEnabled: jest.fn(), }); return setupContract; diff --git a/src/core/packages/saved-objects/server/index.ts b/src/core/packages/saved-objects/server/index.ts index a309aedc47c4f..2b4a586d40596 100644 --- a/src/core/packages/saved-objects/server/index.ts +++ b/src/core/packages/saved-objects/server/index.ts @@ -35,6 +35,8 @@ export type { SavedObjectsImportOptions, SavedObjectsResolveImportErrorsOptions, CreatedObject, + AccessControlImportTransforms, + AccessControlImportTransformsFactory, } from './src/import'; export type { SavedObjectsTypeMappingDefinition, @@ -86,6 +88,8 @@ export type { EncryptedObjectDescriptor, } from './src/extensions/encryption'; export type { + AuthorizeObject, + AuthorizationResult, AuthorizationTypeEntry, AuthorizationTypeMap, CheckAuthorizationResult, @@ -111,6 +115,8 @@ export type { AuthorizeUpdateSpacesParams, AuthorizeFindParams, WithAuditName, + AuthorizeChangeAccessControlParams, + SetAccessControlToWriteParams, } from './src/extensions/security'; export type { ISavedObjectsSpacesExtension } from './src/extensions/spaces'; export type { SavedObjectsExtensions } from './src/extensions/extensions'; @@ -121,6 +127,7 @@ export { } from './src/extensions/extensions'; export { SavedObjectsErrorHelpers, + errorContent, type DecoratedError, type BulkResolveError, } from './src/saved_objects_error_helpers'; @@ -154,6 +161,7 @@ export type { // We re-export the SavedObject types here for convenience. export type { SavedObject, + SavedObjectAccessControl, SavedObjectAttribute, SavedObjectAttributes, SavedObjectAttributeSingle, diff --git a/src/core/packages/saved-objects/server/src/contracts.ts b/src/core/packages/saved-objects/server/src/contracts.ts index 98496bde92b60..6dd4a7a240eb3 100644 --- a/src/core/packages/saved-objects/server/src/contracts.ts +++ b/src/core/packages/saved-objects/server/src/contracts.ts @@ -23,9 +23,17 @@ import type { import type { SavedObjectsType } from './saved_objects_type'; import type { ISavedObjectTypeRegistry } from './type_registry'; import type { ISavedObjectsExporter } from './export'; -import type { ISavedObjectsImporter, SavedObjectsImporterOptions } from './import'; +import type { + AccessControlImportTransformsFactory, + ISavedObjectsImporter, + SavedObjectsImporterOptions, +} from './import'; import type { SavedObjectsExtensions } from './extensions/extensions'; +export interface SavedObjectsAccessControlTransforms { + createImportTransforms: AccessControlImportTransformsFactory; +} + /** * Saved Objects is Kibana's data persistence mechanism allowing plugins to * use Elasticsearch for storing and querying state. The SavedObjectsServiceSetup API exposes methods @@ -84,6 +92,11 @@ export interface SavedObjectsServiceSetup { */ setSpacesExtension: (factory: SavedObjectsSpacesExtensionFactory) => void; + /** + * Sets the {@link SavedObjectsAccessControlTransforms access control transforms}. + */ + setAccessControlTransforms: (transforms: SavedObjectsAccessControlTransforms) => void; + /** * Register a {@link SavedObjectsType | savedObjects type} definition. * @@ -138,6 +151,11 @@ export interface SavedObjectsServiceSetup { * Returns the default index used for saved objects. */ getDefaultIndex: () => string; + + /** + * Returns whether the access control feature is enabled for saved objects. + */ + isAccessControlEnabled: () => boolean; } /** diff --git a/src/core/packages/saved-objects/server/src/extensions/security.ts b/src/core/packages/saved-objects/server/src/extensions/security.ts index 9c2baca1e5635..d2390ec0a7c68 100644 --- a/src/core/packages/saved-objects/server/src/extensions/security.ts +++ b/src/core/packages/saved-objects/server/src/extensions/security.ts @@ -8,12 +8,15 @@ */ import type { + Either, + SavedObjectAccessControl, SavedObjectReferenceWithContext, SavedObjectsFindResult, SavedObjectsResolveResponse, } from '@kbn/core-saved-objects-api-server'; import type { LegacyUrlAliasTarget } from '@kbn/core-saved-objects-common'; import type { AuthenticatedUser } from '@kbn/core-security-common'; +import type { Payload } from '@hapi/boom'; import type { SavedObject, BulkResolveError } from '../..'; /** @@ -56,6 +59,19 @@ export interface CheckAuthorizationResult { typeMap: AuthorizationTypeMap; } +/** + * The AuthorizationResult interface contains the overall status of an + * authorization check, the specific authorized privileges as an + * AuthorizationTypeMap, and the inaccessible objects that were restricted + * due to access control. + */ +export interface AuthorizationResult extends CheckAuthorizationResult { + /** + * A set of all inaccessible objects that were restricted due to access control + */ + inaccessibleObjects?: Set; +} + /** * The AuthorizeObject interface contains information to specify an * object for authorization. This is a base interface which is @@ -68,6 +84,8 @@ export interface AuthorizeObject { id: string; /** The name of the object */ name?: string; + /** Access control information for the object */ + accessControl?: SavedObjectAccessControl; } /** @@ -130,7 +148,7 @@ export interface AuthorizeCreateObject extends AuthorizeObjectWithExistingSpaces /** * The AuthorizeUpdateObject interface extends AuthorizeObjectWithExistingSpaces - * and contains a object namespace override. Used by the authorizeUpdate + * and contains an object namespace override. Used by the authorizeUpdate * and authorizeBulkUpdate methods. */ export interface AuthorizeUpdateObject extends AuthorizeObjectWithExistingSpaces { @@ -141,6 +159,42 @@ export interface AuthorizeUpdateObject extends AuthorizeObjectWithExistingSpaces objectNamespace?: string; } +/** + * The AuthorizeChangeAccessControlObject interface extends AuthorizeObjectWithExistingSpaces + * and contains an object namespace override. Used by the authorizeChangeAccessControl + * method. + */ +export interface AuthorizeChangeAccessControlObject extends AuthorizeObjectWithExistingSpaces { + /** + * The namespace in which to update this object. Populated by options + * passed to the repository's changeOwnership method. + */ + objectNamespace?: string; +} + +/** + * The ObjectRequiringPrivilegeCheckResult interface represents the authorization + * result for a single saved object and includes a flag indicating whether the object + * requires a check for the manage access control privilege. + */ +export interface ObjectRequiringPrivilegeCheckResult { + type: string; + id: string; + name?: string; + requiresManageAccessControl: boolean; +} + +/** + * The GetObjectsRequiringPrivilegeCheckResult interface represents the authorization + * result for an array of saved object and includes both a set of types that require + * a check for the manage access control privilege, and an array of objects each with + * and individual requiresManageAccessControl flag. + */ +export interface GetObjectsRequiringPrivilegeCheckResult { + types: Set; + objects: ObjectRequiringPrivilegeCheckResult[]; +} + /** * The MultiNamespaceReferencesOptions interface contains options * specific for authorizing CollectMultiNamespaceReferences actions. @@ -254,6 +308,15 @@ export interface AuthorizeFindParams { */ export type AuthorizeOpenPointInTimeParams = AuthorizeFindParams; +/** + * The AuthorizeChangeAccessControlParams interface extends AuthorizeParams and is + * used for the AuthorizeChangeAccessControl method of the ISavedObjectsSecurityExtension. + */ +export interface AuthorizeChangeAccessControlParams extends AuthorizeParams { + /** The objects to authorize */ + objects: AuthorizeChangeAccessControlObject[]; +} + /** * The AuthorizeAndRedactMultiNamespaceReferencesParams interface extends * AuthorizeParams and is used for the AuthorizeAndRedactMultiNamespaceReferences @@ -326,6 +389,22 @@ export interface RedactNamespacesParams { export type WithAuditName = T & { name?: string }; +/** + * The SetAccessControlToWriteParams interface defines the parameters for the setAccessControlToWrite + * function. It includes the incoming access control mode, saved object type, the createdBy + * (current user profile ID if it exists), and the accessControl attributes from the prfelight check. + */ +export interface SetAccessControlToWriteParams { + /** The access control mode (default | write_restricted) */ + accessMode: SavedObjectAccessControl['accessMode'] | undefined; + /** The saved object type */ + type: string; + /** The relevant user profile ID for the operation - used in create and bulk create */ + createdBy?: string; + /** The existing access control metadata from the operation's preflight check */ + preflightAccessControl?: SavedObjectAccessControl; +} + /** * The ISavedObjectsSecurityExtension interface defines the functions of a saved objects repository security extension. * It contains functions for checking & enforcing authorization, adding audit events, and redacting namespaces. @@ -334,83 +413,81 @@ export interface ISavedObjectsSecurityExtension { /** * Performs authorization for the CREATE security action * @param params the namespace and object to authorize - * @returns CheckAuthorizationResult - the resulting authorization level and authorization map + * @returns AuthorizationResult - the resulting authorization level and authorization map */ authorizeCreate: ( params: AuthorizeCreateParams - ) => Promise>; + ) => Promise>; /** * Performs authorization for the BULK_CREATE security action * @param params the namespace and objects to authorize - * @returns CheckAuthorizationResult - the resulting authorization level and authorization map + * @returns AuthorizationResult - the resulting authorization level and authorization map */ authorizeBulkCreate: ( params: AuthorizeBulkCreateParams - ) => Promise>; + ) => Promise>; /** * Performs authorization for the UPDATE security action * @param params the namespace and object to authorize - * @returns CheckAuthorizationResult - the resulting authorization level and authorization map + * @returns AuthorizationResult - the resulting authorization level and authorization map */ authorizeUpdate: ( params: AuthorizeUpdateParams - ) => Promise>; + ) => Promise>; /** * Performs authorization for the BULK_UPDATE security action * @param params the namespace and objects to authorize - * @returns CheckAuthorizationResult - the resulting authorization level and authorization map + * @returns AuthorizationResult - the resulting authorization level and authorization map */ authorizeBulkUpdate: ( params: AuthorizeBulkUpdateParams - ) => Promise>; + ) => Promise>; /** * Performs authorization for the DELETE security action * @param params the namespace and object to authorize - * @returns CheckAuthorizationResult - the resulting authorization level and authorization map + * @returns AuthorizationResult - the resulting authorization level and authorization map */ authorizeDelete: ( params: AuthorizeDeleteParams - ) => Promise>; + ) => Promise>; /** * Performs authorization for the BULK_DELETE security action * @param params the namespace and objects to authorize - * @returns CheckAuthorizationResult - the resulting authorization level and authorization map + * @returns AuthorizationResult - the resulting authorization level and authorization map */ authorizeBulkDelete: ( params: AuthorizeBulkDeleteParams - ) => Promise>; + ) => Promise>; /** * Performs authorization for the GET security action * @param params the namespace, object to authorize, and whether or not the object was found - * @returns CheckAuthorizationResult - the resulting authorization level and authorization map + * @returns AuthorizationResult - the resulting authorization level and authorization map */ - authorizeGet: ( - params: AuthorizeGetParams - ) => Promise>; + authorizeGet: (params: AuthorizeGetParams) => Promise>; /** * Performs authorization for the BULK_GET security action * @param params the namespace and objects to authorize - * @returns CheckAuthorizationResult - the resulting authorization level and authorization map + * @returns AuthorizationResult - the resulting authorization level and authorization map */ authorizeBulkGet: ( params: AuthorizeBulkGetParams - ) => Promise>; + ) => Promise>; /** * Performs authorization for the CHECK_CONFLICTS security action * @param params the namespace and objects to authorize - * @returns CheckAuthorizationResult - the resulting authorization level and authorization map + * @returns AuthorizationResult - the resulting authorization level and authorization map */ authorizeCheckConflicts: ( params: AuthorizeCheckConflictsParams - ) => Promise>; + ) => Promise>; /** * Performs authorization for the REMOVE_REFERENCES security action. Checks for authorization @@ -420,20 +497,31 @@ export interface ISavedObjectsSecurityExtension { * (e.g. deleting a tag). * See discussion here: https://github.com/elastic/kibana/issues/135259#issuecomment-1482515139 * @param params the namespace and object to authorize - * @returns CheckAuthorizationResult - the resulting authorization level and authorization map + * @returns AuthorizationResult - the resulting authorization level and authorization map */ authorizeRemoveReferences: ( params: AuthorizeDeleteParams - ) => Promise>; + ) => Promise>; /** * Performs authorization for the OPEN_POINT_IN_TIME security action * @param params the namespaces and types to authorize - * @returns CheckAuthorizationResult - the resulting authorization level and authorization map + * @returns AuthorizationResult - the resulting authorization level and authorization map */ authorizeOpenPointInTime: ( params: AuthorizeOpenPointInTimeParams - ) => Promise>; + ) => Promise>; + + /** + * Performs authorization for the CHANGE_OWNERSHIP or CHANGE_ACCESS_MODE security actions + * @param params the namespace and object to authorize for changing ownership + * @param operation the operation to authorize - one of 'changeAccessMode' or 'changeOwnership' + * @returns AuthorizationResult - the resulting authorization level and authorization map + */ + authorizeChangeAccessControl: ( + params: AuthorizeChangeAccessControlParams, + operation: 'changeAccessMode' | 'changeOwnership' + ) => Promise>; /** * Performs audit logging for the CLOSE_POINT_IN_TIME security action @@ -468,22 +556,20 @@ export interface ISavedObjectsSecurityExtension { /** * Performs authorization for the UPDATE_OBJECTS_SPACES security action * @param params - namespace, spacesToAdd, spacesToRemove, and objects to authorize - * @returns CheckAuthorizationResult - the resulting authorization level and authorization map + * @returns AuthorizationResult - the resulting authorization level and authorization map */ authorizeUpdateSpaces: ( params: AuthorizeUpdateSpacesParams - ) => Promise>; + ) => Promise>; /** * Performs authorization for the FIND security action * This method is the first of two security steps for the find operation (saved objects repository's find method) * This method should be called first in order to provide data needed to construct the type-to-namespace map for the search DSL * @param params - namespaces and types to authorize - * @returns CheckAuthorizationResult - the resulting authorization level and authorization map + * @returns AuthorizationResult - the resulting authorization level and authorization map */ - authorizeFind: ( - params: AuthorizeFindParams - ) => Promise>; + authorizeFind: (params: AuthorizeFindParams) => Promise>; /** * Gets an updated type map for redacting results of the FIND security action @@ -529,4 +615,17 @@ export interface ISavedObjectsSecurityExtension { * Retrieves whether we need to include save objects names in the audit out */ includeSavedObjectNames: () => boolean; + + /** + * Filters bulk operation expected results array to filter inaccessible object left + */ + filterInaccessibleObjectsForBulkAction< + L extends { type: string; id?: string; error: Payload }, + R extends { type: string; id: string; esRequestIndex?: number } + >( + expectedResults: Array>, + inaccessibleObjects: Array<{ type: string; id: string }>, + action: 'bulk_create' | 'bulk_update' | 'bulk_delete', // could alternatively move the SecurityAction definition to a core package to reference here + reindex?: boolean // will re-index the esRequestIndex field (used only in bulk_delete) + ): Promise>>; } diff --git a/src/core/packages/saved-objects/server/src/import.ts b/src/core/packages/saved-objects/server/src/import.ts index 3a88246e7e56f..c2ca2378ea2d7 100644 --- a/src/core/packages/saved-objects/server/src/import.ts +++ b/src/core/packages/saved-objects/server/src/import.ts @@ -7,13 +7,14 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { Readable } from 'stream'; +import type { Readable, Transform } from 'stream'; import type { SavedObjectsImportRetry, SavedObjectsImportWarning, SavedObjectsImportResponse, + SavedObjectsImportFailure, } from '@kbn/core-saved-objects-common'; -import type { SavedObject } from '..'; +import type { ISavedObjectTypeRegistry, SavedObject } from '..'; /** * Utility class used to import savedObjects. @@ -137,3 +138,13 @@ export interface SavedObjectsImportHookResult { export type SavedObjectsImportHook = ( objects: Array> ) => SavedObjectsImportHookResult | Promise; + +export interface AccessControlImportTransforms { + filterStream: Transform; + mapStream: Transform; +} + +export type AccessControlImportTransformsFactory = ( + typeRegistry: ISavedObjectTypeRegistry, + errors: SavedObjectsImportFailure[] +) => AccessControlImportTransforms; diff --git a/src/core/packages/saved-objects/server/src/saved_objects_error_helpers.ts b/src/core/packages/saved-objects/server/src/saved_objects_error_helpers.ts index cd0b5ea0209b4..556eb6fdcb084 100644 --- a/src/core/packages/saved-objects/server/src/saved_objects_error_helpers.ts +++ b/src/core/packages/saved-objects/server/src/saved_objects_error_helpers.ts @@ -8,6 +8,7 @@ */ import Boom from '@hapi/boom'; +import type { SavedObjectsResolveResponse } from '@kbn/core-saved-objects-api-server'; // 400 - badRequest const CODE_BAD_REQUEST = 'SavedObjectsClient/badRequest'; @@ -44,6 +45,11 @@ export interface DecoratedError extends Boom.Boom { [code]?: string; } +/** + * Extracts the contents of a decorated error to return the attributes for bulk operations. + */ +export const errorContent = (error: DecoratedError) => error.output.payload; + /** * Error result for the internal bulk resolve method. */ @@ -89,6 +95,13 @@ function isSavedObjectsClientError(error: any): error is DecoratedError { return Boolean(error && error[code]); } +/** Type guard used in the repository. */ +export function isBulkResolveError( + result: SavedObjectsResolveResponse | BulkResolveError +): result is BulkResolveError { + return !!(result as BulkResolveError).error; +} + /** * Decorates an bad request error to add information or additional explanation of an error to * provide more context. Bad requests come in a few flavors: unsupported type, invalid version, @@ -114,6 +127,18 @@ export class SavedObjectsErrorHelpers { return isSavedObjectsClientError(error); } + /** + * Determines if an error is a saved objects bulk resolve error + * @public + * @param result the resolve respoonse to check + * @returns boolean - true if result is a saved objects bulk resolve error + */ + public static isBulkResolveError( + result: SavedObjectsResolveResponse | BulkResolveError + ): result is BulkResolveError { + return isBulkResolveError(result); + } + /** * Decorates a bad request error (400) by adding a reason * @public diff --git a/src/core/packages/saved-objects/server/src/saved_objects_type.ts b/src/core/packages/saved-objects/server/src/saved_objects_type.ts index 3a01fc205a47c..bb782229c8e0c 100644 --- a/src/core/packages/saved-objects/server/src/saved_objects_type.ts +++ b/src/core/packages/saved-objects/server/src/saved_objects_type.ts @@ -195,6 +195,18 @@ export interface SavedObjectsType { * If not defined, will use the object's type and id to generate a label. */ getTitle?: (savedObject: Attributes) => string; + + /** + * If defined and set to `true`, this saved object type will support access control functionality. + * + * When enabled, objects of this type can have an `accessControl` property containing: + * - `owner`: The ID of the user who owns this object + * - `accessMode`: Access mode setting, supports 'write_restricted' or 'default'. + * + * This property works in conjunction with the SavedObjectAccessControl interface defined + * in server_types.ts. + */ + supportsAccessControl?: boolean; } /** diff --git a/src/core/packages/saved-objects/server/src/serialization.ts b/src/core/packages/saved-objects/server/src/serialization.ts index 72a3f88729408..db7801a099e36 100644 --- a/src/core/packages/saved-objects/server/src/serialization.ts +++ b/src/core/packages/saved-objects/server/src/serialization.ts @@ -8,6 +8,7 @@ */ import type { SavedObjectsMigrationVersion } from '@kbn/core-saved-objects-common'; +import type { SavedObjectAccessControl } from '@kbn/core-saved-objects-api-server'; import type { SavedObjectReference, SavedObjectsRawDocSource } from '..'; /** @@ -95,6 +96,7 @@ export interface SavedObjectDoc { created_by?: string; originId?: string; managed?: boolean; + accessControl?: SavedObjectAccessControl; } /** diff --git a/src/core/packages/saved-objects/server/src/type_registry.ts b/src/core/packages/saved-objects/server/src/type_registry.ts index 7659d29b416a2..e38b778030a3f 100644 --- a/src/core/packages/saved-objects/server/src/type_registry.ts +++ b/src/core/packages/saved-objects/server/src/type_registry.ts @@ -12,7 +12,9 @@ import type { SavedObjectsType } from './saved_objects_type'; * Registry holding information about all the registered {@link SavedObjectsType | saved object types}. */ export interface ISavedObjectTypeRegistry { - /** Return legacy types, this types can't be registered */ + /** + * Return legacy types, this types can't be registered + */ getLegacyTypes(): string[]; /** @@ -98,4 +100,9 @@ export interface ISavedObjectTypeRegistry { * the property/type is not registered. */ getNameAttribute(type: string): string; + + /** + * Returns whether the type supports access control. + */ + supportsAccessControl(type: string): boolean; } diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 96c9e13e90a8d..81d9cd58fcc5b 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -183,6 +183,7 @@ export type { SavedObjectsImportAmbiguousConflictError, SavedObjectsImportUnsupportedTypeError, SavedObjectsImportMissingReferencesError, + SavedObjectsImportUnexpectedAccessControlMetadataError, SavedObjectsImportUnknownError, SavedObjectsImportFailure, SavedObjectsImportRetry, diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 6710efefc0395..73e6ccf9305a7 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -365,6 +365,7 @@ export type { } from '@kbn/core-saved-objects-api-server'; export type { SavedObject, + SavedObjectAccessControl, SavedObjectAttribute, SavedObjectAttributes, SavedObjectAttributeSingle, diff --git a/src/core/test-helpers/model-versions/src/test_bed/test_kit.ts b/src/core/test-helpers/model-versions/src/test_bed/test_kit.ts index f0912f32856bb..883bb901d1fb4 100644 --- a/src/core/test-helpers/model-versions/src/test_bed/test_kit.ts +++ b/src/core/test-helpers/model-versions/src/test_bed/test_kit.ts @@ -29,7 +29,7 @@ import { } from '@kbn/core-elasticsearch-server-internal'; import { AgentManager, configureClient } from '@kbn/core-elasticsearch-client-server-internal'; import { type LoggingConfigType, LoggingSystem } from '@kbn/core-logging-server-internal'; -import type { ISavedObjectTypeRegistry } from '@kbn/core-saved-objects-server'; +import type { ISavedObjectTypeRegistryInternal } from '@kbn/core-saved-objects-base-server-internal'; import { esTestConfig, kibanaServerTestUser } from '@kbn/test'; import type { LoggerFactory } from '@kbn/logging'; import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; @@ -227,7 +227,7 @@ const getMigrator = async ({ configService: ConfigService; client: ElasticsearchClient; kibanaIndex: string; - typeRegistry: ISavedObjectTypeRegistry; + typeRegistry: ISavedObjectTypeRegistryInternal; defaultIndexTypesMap: IndexTypesMap; hashToVersionMap: Record; loggerFactory: LoggerFactory; diff --git a/src/platform/packages/private/kbn-migrator-test-kit/src/kibana_migrator_test_kit.ts b/src/platform/packages/private/kbn-migrator-test-kit/src/kibana_migrator_test_kit.ts index 3c2b4faffe319..5185a0714b851 100644 --- a/src/platform/packages/private/kbn-migrator-test-kit/src/kibana_migrator_test_kit.ts +++ b/src/platform/packages/private/kbn-migrator-test-kit/src/kibana_migrator_test_kit.ts @@ -26,6 +26,7 @@ import { type IKibanaMigrator, type MigrationResult, type IndexTypesMap, + type ISavedObjectTypeRegistryInternal, } from '@kbn/core-saved-objects-base-server-internal'; import { SavedObjectsRepository } from '@kbn/core-saved-objects-api-server-internal'; import { @@ -286,7 +287,7 @@ interface GetMigratorParams { configService: ConfigService; client: ElasticsearchClient; kibanaIndex: string; - typeRegistry: ISavedObjectTypeRegistry; + typeRegistry: ISavedObjectTypeRegistryInternal; defaultIndexTypesMap: IndexTypesMap; hashToVersionMap: Record; loggerFactory: LoggerFactory; diff --git a/src/platform/packages/shared/content-management/access_control/access_control_public/README.md b/src/platform/packages/shared/content-management/access_control/access_control_public/README.md new file mode 100644 index 0000000000000..ca1cadd7c2519 --- /dev/null +++ b/src/platform/packages/shared/content-management/access_control/access_control_public/README.md @@ -0,0 +1,3 @@ +# @kbn/content-management-access-control-public + +Helpers for access control management. diff --git a/src/platform/packages/shared/content-management/access_control/access_control_public/index.ts b/src/platform/packages/shared/content-management/access_control/access_control_public/index.ts new file mode 100644 index 0000000000000..f1eeadf08bc52 --- /dev/null +++ b/src/platform/packages/shared/content-management/access_control/access_control_public/index.ts @@ -0,0 +1,18 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { AccessControlClient, type AccessControlClientPublic } from './src'; +export { AccessModeContainer } from './src'; +export type { + CheckGlobalPrivilegeResponse, + ChangeAccesModeParameters, + ChangeAccessModeResponse, + CheckUserAccessControlParameters, + CanManageContentControlParameters, +} from './src'; diff --git a/src/platform/packages/shared/content-management/access_control/access_control_public/jest.config.js b/src/platform/packages/shared/content-management/access_control/access_control_public/jest.config.js new file mode 100644 index 0000000000000..39269002c3bea --- /dev/null +++ b/src/platform/packages/shared/content-management/access_control/access_control_public/jest.config.js @@ -0,0 +1,16 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../../../..', + roots: [ + '/src/platform/packages/shared/content-management/access_control/access_control_public', + ], +}; diff --git a/src/platform/packages/shared/content-management/access_control/access_control_public/kibana.jsonc b/src/platform/packages/shared/content-management/access_control/access_control_public/kibana.jsonc new file mode 100644 index 0000000000000..3c7f42fddf4fb --- /dev/null +++ b/src/platform/packages/shared/content-management/access_control/access_control_public/kibana.jsonc @@ -0,0 +1,9 @@ +{ + "type": "shared-common", + "id": "@kbn/content-management-access-control-public", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "shared" +} diff --git a/src/platform/packages/shared/content-management/access_control/access_control_public/moon.yml b/src/platform/packages/shared/content-management/access_control/access_control_public/moon.yml new file mode 100644 index 0000000000000..e717ce40159af --- /dev/null +++ b/src/platform/packages/shared/content-management/access_control/access_control_public/moon.yml @@ -0,0 +1,54 @@ +# This file is generated by the @kbn/moon package. Any manual edits will be erased! +# To extend this, write your extensions/overrides to 'moon.extend.yml' +# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/content-management-access-control-public' + +$schema: https://moonrepo.dev/schemas/project.json +id: '@kbn/content-management-access-control-public' +type: unknown +owners: + defaultOwner: '@elastic/appex-sharedux' +toolchain: + default: node +language: typescript +project: + name: '@kbn/content-management-access-control-public' + description: Moon project for @kbn/content-management-access-control-public + channel: '' + owner: '@elastic/appex-sharedux' + metadata: + sourceRoot: src/platform/packages/shared/content-management/access_control/access_control_public +dependsOn: + - '@kbn/core-http-browser' + - '@kbn/core-saved-objects-common' + - '@kbn/core-saved-objects-api-server' + - '@kbn/i18n-react' + - '@kbn/i18n' + - '@kbn/spaces-plugin' + - '@kbn/security-plugin' + - '@kbn/user-profile-components' + - '@kbn/test-jest-helpers' +tags: + - shared-common + - package + - prod + - group-platform + - shared + - jest-unit-tests +fileGroups: + src: + - '**/*.ts' + - '**/*.tsx' + - '!target/**/*' +tasks: + jest: + args: + - '--config' + - $projectRoot/jest.config.js + inputs: + - '@group(src)' + jestCI: + args: + - '--config' + - $projectRoot/jest.config.js + inputs: + - '@group(src)' diff --git a/src/platform/packages/shared/content-management/access_control/access_control_public/package.json b/src/platform/packages/shared/content-management/access_control/access_control_public/package.json new file mode 100644 index 0000000000000..11b3f1825bdad --- /dev/null +++ b/src/platform/packages/shared/content-management/access_control/access_control_public/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/content-management-access-control-public", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" +} diff --git a/src/platform/packages/shared/content-management/access_control/access_control_public/src/access_control_client.ts b/src/platform/packages/shared/content-management/access_control/access_control_public/src/access_control_client.ts new file mode 100644 index 0000000000000..5e6edf7ffce6c --- /dev/null +++ b/src/platform/packages/shared/content-management/access_control/access_control_public/src/access_control_client.ts @@ -0,0 +1,116 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { HttpStart } from '@kbn/core-http-browser'; +import type { SavedObjectAccessControl } from '@kbn/core-saved-objects-common'; +import type { + CanManageContentControlParameters, + ChangeAccesModeParameters, + ChangeAccessModeResponse, + CheckGlobalPrivilegeResponse, + CheckUserAccessControlParameters, + IsAccessControlEnabledResponse, +} from './types'; + +export interface AccessControlClientPublic { + checkGlobalPrivilege(contentTypeId: string): Promise; + changeAccessMode({ + objects, + accessMode, + }: ChangeAccesModeParameters): Promise; + canManageAccessControl(params: CanManageContentControlParameters): Promise; + checkUserAccessControl(params: CheckUserAccessControlParameters): boolean; + isInEditAccessMode(accessControl?: Partial): boolean; + isAccessControlEnabled(): Promise; +} + +export class AccessControlClient implements AccessControlClientPublic { + constructor( + private readonly deps: { + http: HttpStart; + } + ) {} + + async checkGlobalPrivilege(contentTypeId: string): Promise { + const response = await this.deps.http.get( + `/internal/access_control/global_access/${contentTypeId}` + ); + + return { + isGloballyAuthorized: response?.isGloballyAuthorized, + }; + } + + async changeAccessMode({ + objects, + accessMode, + }: ChangeAccesModeParameters): Promise { + const { result } = await this.deps.http.post( + `/internal/access_control/change_access_mode`, + { + body: JSON.stringify({ objects, accessMode }), + } + ); + + return { + result, + }; + } + + async canManageAccessControl({ + accessControl, + createdBy, + userId, + contentTypeId, + }: CanManageContentControlParameters): Promise { + const { isGloballyAuthorized } = await this.checkGlobalPrivilege(contentTypeId); + const canManage = this.checkUserAccessControl({ + accessControl, + createdBy, + userId, + }); + return isGloballyAuthorized || canManage; + } + + checkUserAccessControl({ + accessControl, + createdBy, + userId, + }: CheckUserAccessControlParameters): boolean { + if (!userId) { + return false; + } + + if (!accessControl?.owner) { + // New saved object + if (!createdBy) { + return true; + } + return userId === createdBy; + } + + return userId === accessControl.owner; + } + + isInEditAccessMode(accessControl?: Partial): boolean { + return ( + !accessControl || + accessControl.accessMode === undefined || + accessControl.accessMode === 'default' + ); + } + + async isAccessControlEnabled(): Promise { + const response = await this.deps.http.get( + '/internal/access_control/is_enabled' + ); + + return response?.isAccessControlEnabled ?? false; + } +} diff --git a/src/platform/packages/shared/content-management/access_control/access_control_public/src/components/access_mode_container.test.tsx b/src/platform/packages/shared/content-management/access_control/access_control_public/src/components/access_mode_container.test.tsx new file mode 100644 index 0000000000000..b047a95cca589 --- /dev/null +++ b/src/platform/packages/shared/content-management/access_control/access_control_public/src/components/access_mode_container.test.tsx @@ -0,0 +1,181 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { renderWithI18n } from '@kbn/test-jest-helpers'; +import { AccessModeContainer } from './access_mode_container'; +import type { SavedObjectAccessControl } from '@kbn/core-saved-objects-common'; +import { act, waitFor, screen } from '@testing-library/react'; + +describe('Access Mode Container', () => { + const mockAccessControlClient = { + canManageAccessControl: jest.fn(), + isInEditAccessMode: jest.fn(), + checkGlobalPrivilege: jest.fn(), + changeAccessMode: jest.fn(), + checkUserAccessControl: jest.fn(), + isAccessControlEnabled: jest.fn(), + } as any; + + const mockGetActiveSpace = jest.fn(); + const mockGetCurrentUser = jest.fn(); + + beforeAll(() => { + mockGetActiveSpace.mockResolvedValue({ + name: 'Default Space', + }); + + mockGetCurrentUser.mockResolvedValue({ + uid: 'user-id', + }); + + mockAccessControlClient.isAccessControlEnabled.mockResolvedValue(true); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const getDefaultProps = (accessControl?: Partial) => ({ + onChangeAccessMode: jest.fn(), + accessControl, + getCurrentUser: mockGetCurrentUser, + accessControlClient: mockAccessControlClient, + getActiveSpace: mockGetActiveSpace, + contentTypeId: 'dashboard', + }); + + it('should render access mode container', async () => { + mockAccessControlClient.canManageAccessControl.mockResolvedValue(true); + mockAccessControlClient.isInEditAccessMode.mockReturnValue(true); + + const accessControl: SavedObjectAccessControl = { owner: 'user-id', accessMode: 'default' }; + + await act(async () => { + renderWithI18n(); + }); + + const container = screen.getByTestId('accessModeContainer'); + expect(container).toBeInTheDocument(); + }); + + it('should render access mode select when current user can manage access control', async () => { + mockAccessControlClient.canManageAccessControl.mockResolvedValue(true); + mockAccessControlClient.isInEditAccessMode.mockReturnValue(true); + + const accessControl: SavedObjectAccessControl = { owner: 'user-id', accessMode: 'default' }; + + await act(async () => { + renderWithI18n(); + }); + + const select = screen.getByTestId('accessModeSelect'); + + expect(select).toBeInTheDocument(); + }); + + it('should not render access mode select when accessControl is undefined', async () => { + mockAccessControlClient.canManageAccessControl.mockResolvedValue(false); + mockAccessControlClient.isInEditAccessMode.mockReturnValue(true); + + await act(async () => { + renderWithI18n(); + }); + + const select = screen.queryByTestId('dashboardAccessModeSelect'); + + await waitFor(() => { + expect(select).not.toBeInTheDocument(); + }); + }); + + it('should not render access mode select when current user cannot manage access control', async () => { + mockAccessControlClient.canManageAccessControl.mockResolvedValue(false); + mockAccessControlClient.isInEditAccessMode.mockReturnValue(true); + + const accessControl: SavedObjectAccessControl = { owner: 'user-id2', accessMode: 'default' }; + + await act(async () => { + renderWithI18n(); + }); + + const select = screen.queryByTestId('dashboardAccessModeSelect'); + + await waitFor(() => { + expect(select).not.toBeInTheDocument(); + }); + }); + + it('should render space name', async () => { + mockAccessControlClient.canManageAccessControl.mockResolvedValue(false); + mockAccessControlClient.isInEditAccessMode.mockReturnValue(true); + + await act(async () => { + renderWithI18n(); + }); + + const spaceName = screen.getByText(/Default Space/i); + + await waitFor(() => { + expect(spaceName).toBeInTheDocument(); + }); + }); + + it('should render description tooltip when current user cannot manage access control', async () => { + mockAccessControlClient.canManageAccessControl.mockResolvedValue(false); + mockAccessControlClient.isInEditAccessMode.mockReturnValue(true); + + const accessControl: SavedObjectAccessControl = { owner: 'user-id2', accessMode: 'default' }; + + await act(async () => { + renderWithI18n(); + }); + + const tooltip = screen.getByTestId('accessModeContainerDescriptionTooltip'); + + await waitFor(() => { + expect(tooltip).toBeInTheDocument(); + }); + }); + + it('should not render description tooltip when current user can manage access control', async () => { + mockAccessControlClient.canManageAccessControl.mockResolvedValue(true); + mockAccessControlClient.isInEditAccessMode.mockReturnValue(true); + + const accessControl: SavedObjectAccessControl = { owner: 'user-id', accessMode: 'default' }; + + await act(async () => { + renderWithI18n(); + }); + + const tooltip = screen.queryByTestId('accessModeContainerDescriptionTooltip'); + + await waitFor(() => { + expect(tooltip).not.toBeInTheDocument(); + }); + }); + + it('should not render anything when access control is disabled', async () => { + mockAccessControlClient.isAccessControlEnabled.mockResolvedValueOnce(false); + mockAccessControlClient.canManageAccessControl.mockResolvedValue(true); + mockAccessControlClient.isInEditAccessMode.mockReturnValue(true); + + const accessControl: SavedObjectAccessControl = { owner: 'user-id', accessMode: 'default' }; + + await act(async () => { + renderWithI18n(); + }); + + const container = screen.queryByTestId('accessModeContainer'); + + await waitFor(() => { + expect(container).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/platform/packages/shared/content-management/access_control/access_control_public/src/components/access_mode_container.tsx b/src/platform/packages/shared/content-management/access_control/access_control_public/src/components/access_mode_container.tsx new file mode 100644 index 0000000000000..72fc7a47efb56 --- /dev/null +++ b/src/platform/packages/shared/content-management/access_control/access_control_public/src/components/access_mode_container.tsx @@ -0,0 +1,266 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { type ChangeEvent, useState, useEffect } from 'react'; +import { + EuiBadge, + EuiBetaBadge, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, + EuiSelect, + EuiSpacer, + EuiText, + EuiTitle, + EuiToolTip, + useGeneratedHtmlId, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import type { SavedObjectAccessControl } from '@kbn/core-saved-objects-common'; +import type { Space } from '@kbn/spaces-plugin/common'; +import type { GetUserProfileResponse } from '@kbn/security-plugin/common'; +import type { UserProfileData } from '@kbn/user-profile-components'; +import type { AccessControlClient } from '../access_control_client'; + +const selectOptions = [ + { + value: 'default', + text: ( + + ), + }, + { + value: 'write_restricted', + text: ( + + ), + }, +]; + +const getSpaceIcon = (space: Space['solution']) => { + switch (space) { + case 'es': + case 'workplaceai': + return 'logoElasticsearch'; + case 'security': + return 'logoSecurity'; + case 'oblt': + return 'logoObservability'; + case 'classic': + return 'logoElasticStack'; + default: + return undefined; // No icon for default space and serverless spaces + } +}; + +interface Props { + onChangeAccessMode: ( + value: SavedObjectAccessControl['accessMode'] + ) => Promise | string | void; + getActiveSpace?: () => Promise; + getCurrentUser: () => Promise>; + accessControlClient: AccessControlClient; + contentTypeId: string; + accessControl?: Partial; + createdBy?: string; +} + +export const AccessModeContainer = ({ + onChangeAccessMode, + getActiveSpace, + getCurrentUser, + accessControlClient, + contentTypeId, + accessControl, + createdBy, +}: Props) => { + const [space, setSpace] = useState({} as Space); + const [isUpdatingPermissions, setIsUpdatingPermissions] = useState(false); + const [canManageAccessControl, setCanManageAccessControl] = useState(false); + const [isAccessControlEnabled, setIsAccessControlEnabled] = useState(false); + const [tooltipContent, setTooltipContent] = useState(''); + const isInEditAccessMode = accessControlClient.isInEditAccessMode(accessControl); + + useEffect(() => { + const checkAccessControlEnabled = async () => { + const enabled = await accessControlClient.isAccessControlEnabled(); + setIsAccessControlEnabled(enabled); + }; + + checkAccessControlEnabled(); + }, [accessControlClient]); + + useEffect(() => { + getActiveSpace?.().then((activeSpace) => { + setSpace(activeSpace); + }); + }, [getActiveSpace]); + + useEffect(() => { + const getCanManage = async () => { + const user = await getCurrentUser(); + const canManage = await accessControlClient.canManageAccessControl({ + accessControl, + createdBy, + userId: user?.uid, + contentTypeId, + }); + setCanManageAccessControl(canManage); + }; + + getCanManage(); + }, [accessControl, createdBy, accessControlClient, contentTypeId, getCurrentUser]); + + useEffect(() => { + if (tooltipContent) { + const timeout = setTimeout(() => setTooltipContent(''), 2000); + return () => clearTimeout(timeout); + } + }, [tooltipContent]); + + const selectId = useGeneratedHtmlId({ prefix: 'accessControlSelect' }); + + const handleSelectChange = async (e: ChangeEvent) => { + setTooltipContent(''); + setIsUpdatingPermissions(true); + + try { + const result = await onChangeAccessMode( + e.target.value as SavedObjectAccessControl['accessMode'] + ); + + if (result?.length) { + setTooltipContent(result); + } + } catch (error) { + setTooltipContent(''); + } finally { + setIsUpdatingPermissions(false); + } + }; + + if (!isAccessControlEnabled) { + return null; + } + + return ( + + + + + +

+ +

+
+
+ + + +
+
+ + + + + + + + + + + + {space?.name} + + + {!canManageAccessControl && ( + <> + + + + + + + + } + aria-label={i18n.translate( + 'contentManagement.accessControl.accessMode.container.description.tooltipAriaLabel', + { + defaultMessage: 'Only the {contentTypeId} owner can edit permissions.', + values: { contentTypeId }, + } + )} + position="bottom" + /> + + + )} + + + + {canManageAccessControl && ( + + + + )} + + + + +
+ ); +}; diff --git a/src/platform/packages/shared/content-management/access_control/access_control_public/src/components/index.ts b/src/platform/packages/shared/content-management/access_control/access_control_public/src/components/index.ts new file mode 100644 index 0000000000000..57cb7f3c3a4c9 --- /dev/null +++ b/src/platform/packages/shared/content-management/access_control/access_control_public/src/components/index.ts @@ -0,0 +1,10 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { AccessModeContainer } from './access_mode_container'; diff --git a/src/platform/packages/shared/content-management/access_control/access_control_public/src/index.ts b/src/platform/packages/shared/content-management/access_control/access_control_public/src/index.ts new file mode 100644 index 0000000000000..65bfba04c1559 --- /dev/null +++ b/src/platform/packages/shared/content-management/access_control/access_control_public/src/index.ts @@ -0,0 +1,18 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { AccessControlClient, type AccessControlClientPublic } from './access_control_client'; +export { AccessModeContainer } from './components'; +export type { + CheckGlobalPrivilegeResponse, + ChangeAccesModeParameters, + ChangeAccessModeResponse, + CheckUserAccessControlParameters, + CanManageContentControlParameters, +} from './types'; diff --git a/src/platform/packages/shared/content-management/access_control/access_control_public/src/types.ts b/src/platform/packages/shared/content-management/access_control/access_control_public/src/types.ts new file mode 100644 index 0000000000000..8a9b30dedbae4 --- /dev/null +++ b/src/platform/packages/shared/content-management/access_control/access_control_public/src/types.ts @@ -0,0 +1,38 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { SavedObjectsChangeAccessControlResponse } from '@kbn/core-saved-objects-api-server'; +import type { SavedObjectAccessControl } from '@kbn/core-saved-objects-common'; + +export interface CheckGlobalPrivilegeResponse { + isGloballyAuthorized: boolean; +} + +export interface IsAccessControlEnabledResponse { + isAccessControlEnabled: boolean; +} + +export interface ChangeAccesModeParameters { + objects: Array<{ type: string; id: string }>; + accessMode: SavedObjectAccessControl['accessMode']; +} + +export interface ChangeAccessModeResponse { + result: SavedObjectsChangeAccessControlResponse; +} + +export interface CheckUserAccessControlParameters { + accessControl?: Partial; + createdBy?: string; + userId?: string; +} + +export interface CanManageContentControlParameters extends CheckUserAccessControlParameters { + contentTypeId: string; +} diff --git a/src/platform/packages/shared/content-management/access_control/access_control_public/tsconfig.json b/src/platform/packages/shared/content-management/access_control/access_control_public/tsconfig.json new file mode 100644 index 0000000000000..c10375ee2b42e --- /dev/null +++ b/src/platform/packages/shared/content-management/access_control/access_control_public/tsconfig.json @@ -0,0 +1,31 @@ +{ + "extends": "@kbn/tsconfig-base/tsconfig.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "react", + "@testing-library/jest-dom", + "@testing-library/react" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/core-http-browser", + "@kbn/core-saved-objects-common", + "@kbn/core-saved-objects-api-server", + "@kbn/i18n-react", + "@kbn/i18n", + "@kbn/spaces-plugin", + "@kbn/security-plugin", + "@kbn/user-profile-components", + "@kbn/test-jest-helpers", + ] +} diff --git a/src/platform/packages/shared/content-management/access_control/access_control_server/README.md b/src/platform/packages/shared/content-management/access_control/access_control_server/README.md new file mode 100644 index 0000000000000..2e1a0cf74232b --- /dev/null +++ b/src/platform/packages/shared/content-management/access_control/access_control_server/README.md @@ -0,0 +1,3 @@ +# @kbn/content-management-access-control-server + +Helpers for access control management. diff --git a/src/platform/packages/shared/content-management/access_control/access_control_server/index.ts b/src/platform/packages/shared/content-management/access_control/access_control_server/index.ts new file mode 100644 index 0000000000000..2035a8132e81f --- /dev/null +++ b/src/platform/packages/shared/content-management/access_control/access_control_server/index.ts @@ -0,0 +1,11 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { registerAccessControl } from './src'; +export type { CheckGlobalAccessControlPrivilegeDependencies } from './src'; diff --git a/src/platform/packages/shared/content-management/access_control/access_control_server/jest.config.js b/src/platform/packages/shared/content-management/access_control/access_control_server/jest.config.js new file mode 100644 index 0000000000000..0ef1d02fc0ac3 --- /dev/null +++ b/src/platform/packages/shared/content-management/access_control/access_control_server/jest.config.js @@ -0,0 +1,16 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../../../..', + roots: [ + '/src/platform/packages/shared/content-management/access_control/access_control_server', + ], +}; diff --git a/src/platform/packages/shared/content-management/access_control/access_control_server/kibana.jsonc b/src/platform/packages/shared/content-management/access_control/access_control_server/kibana.jsonc new file mode 100644 index 0000000000000..c36259194ba63 --- /dev/null +++ b/src/platform/packages/shared/content-management/access_control/access_control_server/kibana.jsonc @@ -0,0 +1,9 @@ +{ + "type": "shared-common", + "id": "@kbn/content-management-access-control-server", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "shared" +} diff --git a/src/platform/packages/shared/content-management/access_control/access_control_server/moon.yml b/src/platform/packages/shared/content-management/access_control/access_control_server/moon.yml new file mode 100644 index 0000000000000..bcccfd5371913 --- /dev/null +++ b/src/platform/packages/shared/content-management/access_control/access_control_server/moon.yml @@ -0,0 +1,48 @@ +# This file is generated by the @kbn/moon package. Any manual edits will be erased! +# To extend this, write your extensions/overrides to 'moon.extend.yml' +# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/content-management-access-control-server' + +$schema: https://moonrepo.dev/schemas/project.json +id: '@kbn/content-management-access-control-server' +type: unknown +owners: + defaultOwner: '@elastic/appex-sharedux' +toolchain: + default: node +language: typescript +project: + name: '@kbn/content-management-access-control-server' + description: Moon project for @kbn/content-management-access-control-server + channel: '' + owner: '@elastic/appex-sharedux' + metadata: + sourceRoot: src/platform/packages/shared/content-management/access_control/access_control_server +dependsOn: + - '@kbn/core' + - '@kbn/security-plugin-types-server' + - '@kbn/core-saved-objects-common' + - '@kbn/config-schema' +tags: + - shared-common + - package + - prod + - group-platform + - shared + - jest-unit-tests +fileGroups: + src: + - '**/*.ts' + - '!target/**/*' +tasks: + jest: + args: + - '--config' + - $projectRoot/jest.config.js + inputs: + - '@group(src)' + jestCI: + args: + - '--config' + - $projectRoot/jest.config.js + inputs: + - '@group(src)' diff --git a/src/platform/packages/shared/content-management/access_control/access_control_server/package.json b/src/platform/packages/shared/content-management/access_control/access_control_server/package.json new file mode 100644 index 0000000000000..a09816d7ce1e3 --- /dev/null +++ b/src/platform/packages/shared/content-management/access_control/access_control_server/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/content-management-access-control-server", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" +} diff --git a/src/platform/packages/shared/content-management/access_control/access_control_server/src/index.ts b/src/platform/packages/shared/content-management/access_control/access_control_server/src/index.ts new file mode 100644 index 0000000000000..ec6dacb23c8ae --- /dev/null +++ b/src/platform/packages/shared/content-management/access_control/access_control_server/src/index.ts @@ -0,0 +1,11 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { registerAccessControl } from './register_access_control'; +export type { CheckGlobalAccessControlPrivilegeDependencies } from './types'; diff --git a/src/platform/packages/shared/content-management/access_control/access_control_server/src/register_access_control.ts b/src/platform/packages/shared/content-management/access_control/access_control_server/src/register_access_control.ts new file mode 100644 index 0000000000000..3966f6abf1a87 --- /dev/null +++ b/src/platform/packages/shared/content-management/access_control/access_control_server/src/register_access_control.ts @@ -0,0 +1,188 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { SavedObjectAccessControl } from '@kbn/core-saved-objects-common'; +import { schema } from '@kbn/config-schema'; +import type { CheckGlobalAccessControlPrivilegeDependencies } from './types'; + +export const registerAccessControl = async ({ + http, + isAccessControlEnabled, + getStartServices, +}: CheckGlobalAccessControlPrivilegeDependencies) => { + const router = http.createRouter(); + + router.get( + { + path: '/internal/access_control/global_access/{contentTypeId}', + validate: { + request: { + params: schema.object({ + contentTypeId: schema.string(), + }), + }, + response: { + 200: { + body: () => + schema.object({ + isGloballyAuthorized: schema.boolean(), + }), + }, + }, + }, + security: { + authz: { + enabled: false, + reason: 'This route checks access control privileges', + }, + }, + }, + async (_ctx, request, response) => { + if (!isAccessControlEnabled) { + return response.ok({ + body: { + isGloballyAuthorized: true, + }, + }); + } + + const { security } = await getStartServices(); + const contentTypeId = request.params.contentTypeId; + + const authorization = security?.authz; + + if (!authorization) { + return response.ok({ + body: { + isGloballyAuthorized: false, + }, + }); + } + + const privileges = { + kibana: authorization.actions.savedObject.get(contentTypeId, 'manage_access_control'), + }; + + const { hasAllRequested } = await authorization + .checkPrivilegesWithRequest(request) + .globally(privileges); + + return response.ok({ + body: { + isGloballyAuthorized: hasAllRequested, + }, + }); + } + ); + + router.post( + { + path: '/internal/access_control/change_access_mode', + validate: { + request: { + body: schema.object({ + objects: schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + }) + ), + accessMode: schema.oneOf([ + schema.literal('write_restricted'), + schema.literal('default'), + ]), + }), + }, + response: { + 200: { + body: () => + schema.object({ + results: schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + success: schema.boolean(), + error: schema.maybe( + schema.object({ + message: schema.string(), + statusCode: schema.number(), + }) + ), + }) + ), + }), + }, + }, + }, + security: { + authz: { + enabled: false, + reason: 'This route changes the access mode of saved objects', + }, + }, + }, + async (ctx, request, response) => { + if (!isAccessControlEnabled) { + return response.badRequest({ body: 'Access control is not enabled' }); + } + + try { + const core = await ctx.core; + const { savedObjects } = core; + const client = savedObjects.getClient(); + const { objects, accessMode } = request.body; + + const result = await client.changeAccessMode(objects, { + accessMode, + } as SavedObjectAccessControl); + + return response.ok({ + body: { + result, + }, + }); + } catch (error) { + return response.badRequest({ body: error }); + } + } + ); + + router.get( + { + path: '/internal/access_control/is_enabled', + validate: { + request: {}, + response: { + 200: { + body: () => + schema.object({ + isAccessControlEnabled: schema.boolean(), + }), + }, + }, + }, + security: { + authz: { + enabled: false, + reason: 'This route returns the access control enabled status', + }, + }, + }, + async (_ctx, request, response) => { + const { security: securityStart } = await getStartServices(); + const useRbacForRequest = securityStart?.authz.mode.useRbacForRequest(request); + const enabled = isAccessControlEnabled && useRbacForRequest; + return response.ok({ + body: { + isAccessControlEnabled: enabled, + }, + }); + } + ); +}; diff --git a/src/platform/packages/shared/content-management/access_control/access_control_server/src/types.ts b/src/platform/packages/shared/content-management/access_control/access_control_server/src/types.ts new file mode 100644 index 0000000000000..17ba7fb66faa1 --- /dev/null +++ b/src/platform/packages/shared/content-management/access_control/access_control_server/src/types.ts @@ -0,0 +1,19 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { CoreSetup } from '@kbn/core/server'; +import type { SecurityPluginStart } from '@kbn/security-plugin-types-server'; + +export interface CheckGlobalAccessControlPrivilegeDependencies { + http: CoreSetup['http']; + isAccessControlEnabled: boolean; + getStartServices: () => Promise<{ + security?: SecurityPluginStart; + }>; +} diff --git a/src/platform/packages/shared/content-management/access_control/access_control_server/tsconfig.json b/src/platform/packages/shared/content-management/access_control/access_control_server/tsconfig.json new file mode 100644 index 0000000000000..ab729b1fcc8e0 --- /dev/null +++ b/src/platform/packages/shared/content-management/access_control/access_control_server/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "@kbn/tsconfig-base/tsconfig.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/core", + "@kbn/security-plugin-types-server", + "@kbn/core-saved-objects-common", + "@kbn/config-schema", + ] +} diff --git a/src/platform/packages/shared/content-management/content_editor/src/components/editor_flyout_content.tsx b/src/platform/packages/shared/content-management/content_editor/src/components/editor_flyout_content.tsx index 4e9181ebf01d5..83ea444a0fcab 100644 --- a/src/platform/packages/shared/content-management/content_editor/src/components/editor_flyout_content.tsx +++ b/src/platform/packages/shared/content-management/content_editor/src/components/editor_flyout_content.tsx @@ -16,14 +16,10 @@ import { EuiFlyoutBody, EuiFlyoutFooter, EuiTitle, + EuiButton, EuiFlexGroup, EuiFlexItem, - EuiButton, - EuiButtonEmpty, - EuiIcon, - useEuiTheme, } from '@elastic/eui'; -import { css } from '@emotion/react'; import type { Services } from '../services'; import type { Item } from '../types'; @@ -38,9 +34,6 @@ const getI18nTexts = ({ entityName }: { entityName: string }) => ({ entityName, }, }), - cancelButtonLabel: i18n.translate('contentManagement.contentEditor.cancelButtonLabel', { - defaultMessage: 'Cancel', - }), }); export interface Props { @@ -56,7 +49,6 @@ export interface Props { tags: string[]; }) => Promise; customValidators?: CustomValidators; - onCancel: () => void; appendRows?: React.ReactNode; } @@ -69,11 +61,9 @@ export const ContentEditorFlyoutContent: FC = ({ readonlyReason, services: { TagSelector, TagList, notifyError }, onSave, - onCancel, customValidators, appendRows, }) => { - const { euiTheme } = useEuiTheme(); const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitted, setIsSubmitted] = useState(false); const i18nTexts = useMemo(() => getI18nTexts({ entityName }), [entityName]); @@ -128,14 +118,6 @@ export const ContentEditorFlyoutContent: FC = ({ setIsSubmitted(true); }, [onSave, item.id, form, notifyError, entityName]); - const onClickCancel = useCallback(() => { - onCancel(); - }, [onCancel]); - - const iconCSS = css` - margin-right: ${euiTheme.size.m}; - `; - const title = capitalize( i18n.translate('contentManagement.contentEditor.flyoutTitle', { defaultMessage: '{entityName} details', @@ -150,12 +132,10 @@ export const ContentEditorFlyoutContent: FC = ({

- {title}

- = ({ {appendRows} - - <> - + {isReadonly === false && ( + - - {i18nTexts.cancelButtonLabel} - + {i18nTexts.saveButtonLabel} + - - {isReadonly === false && ( - - - {i18nTexts.saveButtonLabel} - - - )} - + )} ); diff --git a/src/platform/packages/shared/content-management/content_editor/src/components/editor_flyout_content_container.tsx b/src/platform/packages/shared/content-management/content_editor/src/components/editor_flyout_content_container.tsx index 19245168a2fca..0c7b1cfa9f36c 100644 --- a/src/platform/packages/shared/content-management/content_editor/src/components/editor_flyout_content_container.tsx +++ b/src/platform/packages/shared/content-management/content_editor/src/components/editor_flyout_content_container.tsx @@ -20,7 +20,6 @@ type CommonProps = Pick< | 'readonlyReason' | 'services' | 'onSave' - | 'onCancel' | 'entityName' | 'customValidators' | 'appendRows' diff --git a/src/platform/packages/shared/content-management/content_editor/src/components/inspector_flyout_content.test.tsx b/src/platform/packages/shared/content-management/content_editor/src/components/inspector_flyout_content.test.tsx index 44ac09d8d666e..125df2f6b8adc 100644 --- a/src/platform/packages/shared/content-management/content_editor/src/components/inspector_flyout_content.test.tsx +++ b/src/platform/packages/shared/content-management/content_editor/src/components/inspector_flyout_content.test.tsx @@ -44,7 +44,6 @@ describe('', () => { item: savedObjectItem, entityName: 'foo', services: mockedServices, - onCancel: jest.fn(), }; const setup = registerTestBed( diff --git a/src/platform/packages/shared/content-management/content_editor/src/components/metadata_form.tsx b/src/platform/packages/shared/content-management/content_editor/src/components/metadata_form.tsx index 6baa65f9bf667..71cc02eec439e 100644 --- a/src/platform/packages/shared/content-management/content_editor/src/components/metadata_form.tsx +++ b/src/platform/packages/shared/content-management/content_editor/src/components/metadata_form.tsx @@ -62,7 +62,10 @@ export const MetadataForm: FC> = ({ {isReadonly && ( - + <> + + + )} > = ({ error={title.errors} isInvalid={!isFormFieldValid(title)} fullWidth + isDisabled={isReadonly} > > = ({ error={description.errors} isInvalid={!isFormFieldValid(description)} fullWidth + isDisabled={isReadonly} > > = ({ defaultMessage: 'Tags', })} fullWidth + isDisabled={isReadonly} > diff --git a/src/platform/packages/shared/content-management/content_editor/src/open_content_editor.tsx b/src/platform/packages/shared/content-management/content_editor/src/open_content_editor.tsx index f710064a39c27..4ec1ced18f1f0 100644 --- a/src/platform/packages/shared/content-management/content_editor/src/open_content_editor.tsx +++ b/src/platform/packages/shared/content-management/content_editor/src/open_content_editor.tsx @@ -42,15 +42,15 @@ export function useOpenContentEditor() { flyout.current?.close(); }; - flyout.current = openFlyout( - , - { - maxWidth: 600, - size: 'm', - ownFocus: true, - hideCloseButton: true, - } - ); + flyout.current = openFlyout(, { + maxWidth: 600, + size: 'm', + ownFocus: true, + onClose: closeFlyout, + closeButtonProps: { + 'data-test-subj': 'closeFlyoutButton', + }, + }); return closeFlyout; }, diff --git a/src/platform/packages/shared/content-management/table_list_view_table/src/table_list_view_table.tsx b/src/platform/packages/shared/content-management/table_list_view_table/src/table_list_view_table.tsx index e37d9be0aaea2..1ff739530950a 100644 --- a/src/platform/packages/shared/content-management/table_list_view_table/src/table_list_view_table.tsx +++ b/src/platform/packages/shared/content-management/table_list_view_table/src/table_list_view_table.tsx @@ -62,7 +62,7 @@ import { ContentEditorActivityRow } from './components/content_editor_activity_r const disabledEditAction = { enabled: false, reason: i18n.translate('contentManagement.tableList.managedItemNoEdit', { - defaultMessage: 'Elastic manages this item. Clone it to make changes.', + defaultMessage: 'Elastic manages this item. Duplicate it to make changes.', }), }; diff --git a/src/platform/packages/shared/shared-ux/modal/tabbed/src/tabbed_modal.test.tsx b/src/platform/packages/shared/shared-ux/modal/tabbed/src/tabbed_modal.test.tsx index d7a81c9cc7ac2..09a1ecb3cfbf0 100644 --- a/src/platform/packages/shared/shared-ux/modal/tabbed/src/tabbed_modal.test.tsx +++ b/src/platform/packages/shared/shared-ux/modal/tabbed/src/tabbed_modal.test.tsx @@ -117,5 +117,34 @@ describe('TabbedModal', () => { expect(mockedHandlerFn).toHaveBeenCalled(); }); + + it('renders AboveTabsContent when provided', () => { + const tabDefinition = getTabDefinition(mockedHandlerFn); + + render( + Content} + /> + ); + + expect(screen.getByTestId('tabbedModal-above-tabs-content')).toBeInTheDocument(); + }); + + it('does not render AboveTabsContent when not provided', () => { + const tabDefinition = getTabDefinition(mockedHandlerFn); + + render( + + ); + + expect(screen.queryByTestId('tabbedModal-above-tabs-content')).not.toBeInTheDocument(); + }); }); }); diff --git a/src/platform/packages/shared/shared-ux/modal/tabbed/src/tabbed_modal.tsx b/src/platform/packages/shared/shared-ux/modal/tabbed/src/tabbed_modal.tsx index e643f36dafc05..63c941a3e6d9b 100644 --- a/src/platform/packages/shared/shared-ux/modal/tabbed/src/tabbed_modal.tsx +++ b/src/platform/packages/shared/shared-ux/modal/tabbed/src/tabbed_modal.tsx @@ -14,6 +14,7 @@ import React, { type ComponentProps, type FC, type ReactElement, + type ReactNode, } from 'react'; import { Global } from '@emotion/react'; import { @@ -64,6 +65,7 @@ export interface ITabbedModalInner modalWidth?: number; modalTitle?: string; anchorElement?: HTMLElement; + aboveTabsContent?: ReactNode; 'data-test-subj'?: string; } @@ -72,6 +74,7 @@ const TabbedModalInner: FC = ({ modalTitle, modalWidth, anchorElement, + aboveTabsContent: AboveTabsContent, outsideClickCloses, ...props }) => { @@ -151,6 +154,9 @@ const TabbedModalInner: FC = ({ {modalTitle} + {AboveTabsContent && ( +
{AboveTabsContent}
+ )} {renderTabs()}
diff --git a/src/platform/plugins/shared/dashboard/kibana.jsonc b/src/platform/plugins/shared/dashboard/kibana.jsonc index 9c04035d12e75..69b938771ce4d 100644 --- a/src/platform/plugins/shared/dashboard/kibana.jsonc +++ b/src/platform/plugins/shared/dashboard/kibana.jsonc @@ -40,6 +40,7 @@ "noDataPage", "observabilityAIAssistant", "lens", + "security", "cps" ], "requiredBundles": [ diff --git a/src/platform/plugins/shared/dashboard/moon.yml b/src/platform/plugins/shared/dashboard/moon.yml index 1185f063b92ad..d91ce5ddfd681 100644 --- a/src/platform/plugins/shared/dashboard/moon.yml +++ b/src/platform/plugins/shared/dashboard/moon.yml @@ -102,6 +102,10 @@ dependsOn: - '@kbn/controls-schemas' - '@kbn/controls-constants' - '@kbn/presentation-util' + - '@kbn/security-plugin-types-server' + - '@kbn/core-saved-objects-common' + - '@kbn/content-management-access-control-public' + - '@kbn/content-management-access-control-server' - '@kbn/react-query' - '@kbn/core-http-server' - '@kbn/core-chrome-layout-utils' diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/access_control_manager.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/access_control_manager.ts new file mode 100644 index 0000000000000..267a33cecf5c0 --- /dev/null +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/access_control_manager.ts @@ -0,0 +1,55 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { BehaviorSubject } from 'rxjs'; +import type { SavedObjectAccessControl } from '@kbn/core-saved-objects-common'; +import { DASHBOARD_SAVED_OBJECT_TYPE } from '@kbn/deeplinks-analytics/constants'; +import type { DashboardReadResponseBody } from '../../server'; +import { getAccessControlClient } from '../services/access_control_service'; + +export function initializeAccessControlManager( + savedObjectResult?: DashboardReadResponseBody, + savedObjectId$?: BehaviorSubject +) { + const accessControl$ = new BehaviorSubject>({ + owner: savedObjectResult?.data?.access_control?.owner, + accessMode: savedObjectResult?.data?.access_control?.access_mode, + }); + + async function changeAccessMode(accessMode: SavedObjectAccessControl['accessMode']) { + const dashboardId = savedObjectId$?.value; + if (!dashboardId) { + throw new Error('Cannot change access mode: Dashboard ID is not available'); + } + + try { + const client = getAccessControlClient(); + + await client.changeAccessMode({ + objects: [{ id: dashboardId, type: DASHBOARD_SAVED_OBJECT_TYPE }], + accessMode: accessMode as SavedObjectAccessControl['accessMode'], + }); + + const currentAccessControl = accessControl$.value; + accessControl$.next({ + ...currentAccessControl, + accessMode, + }); + } catch (error) { + throw error; + } + } + + return { + api: { + accessControl$, + changeAccessMode, + }, + }; +} diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/get_dashboard_api.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/get_dashboard_api.ts index 10aab00b02c1d..46501bd931a98 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/get_dashboard_api.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/get_dashboard_api.ts @@ -19,6 +19,7 @@ import { CONTROL_GROUP_EMBEDDABLE_ID, initializeControlGroupManager, } from './control_group_manager'; +import { initializeAccessControlManager } from './access_control_manager'; import { initializeDataLoadingManager } from './data_loading_manager'; import { initializeDataViewsManager } from './data_views_manager'; import { DEFAULT_DASHBOARD_STATE } from './default_dashboard_state'; @@ -29,7 +30,12 @@ import { initializeSettingsManager } from './settings_manager'; import { initializeTrackContentfulRender } from './track_contentful_render'; import { initializeTrackOverlay } from './track_overlay'; import { initializeTrackPanel } from './track_panel'; -import type { DashboardApi, DashboardCreationOptions, DashboardInternalApi } from './types'; +import type { + DashboardApi, + DashboardCreationOptions, + DashboardInternalApi, + DashboardUser, +} from './types'; import { DASHBOARD_API_TYPE } from './types'; import { initializeUnifiedSearchManager } from './unified_search_manager'; import { initializeProjectRoutingManager } from './project_routing_manager'; @@ -45,22 +51,34 @@ export function getDashboardApi({ initialState, readResult, savedObjectId, + user, + isAccessControlEnabled, }: { creationOptions?: DashboardCreationOptions; incomingEmbeddables?: EmbeddablePackageState[] | undefined; initialState: DashboardState; readResult?: DashboardReadResponseBody; savedObjectId?: string; + user?: DashboardUser; + isAccessControlEnabled?: boolean; }) { const fullScreenMode$ = new BehaviorSubject(creationOptions?.fullScreenMode ?? false); const isManaged = readResult?.meta.managed ?? false; const savedObjectId$ = new BehaviorSubject(savedObjectId); const dashboardContainerRef$ = new BehaviorSubject(null); + const accessControlManager = initializeAccessControlManager(readResult, savedObjectId$); + const viewModeManager = initializeViewModeManager({ incomingEmbeddables, isManaged, savedObjectId, + accessControl: { + accessMode: readResult?.data?.access_control?.access_mode, + owner: readResult?.data?.access_control?.owner, + }, + createdBy: readResult?.meta?.created_by, + user, }); const trackPanel = initializeTrackPanel(async (id: string) => { await layoutManager.api.getChildApi(id); @@ -211,6 +229,7 @@ export function getDashboardApi({ projectRoutingRestore, title, viewMode: viewModeManager.api.viewMode$.value, + accessControl: accessControlManager.api.accessControl$.value, }); if (!saveResult || saveResult.error) { @@ -241,6 +260,7 @@ export function getDashboardApi({ references, saveOptions: {}, lastSavedId: savedObjectId$.value, + accessMode: accessControlManager.api.accessControl$.value?.accessMode, }); if (saveResult?.error) return; @@ -260,6 +280,12 @@ export function getDashboardApi({ type: DASHBOARD_API_TYPE as 'dashboard', uuid: v4(), getPassThroughContext: () => creationOptions?.getPassThroughContext?.(), + createdBy: readResult?.meta?.created_by, + user, + // TODO: accessControl$ and changeAccessMode should be moved to internalApi + accessControl$: accessControlManager.api.accessControl$, + changeAccessMode: accessControlManager.api.changeAccessMode, + isAccessControlEnabled: Boolean(isAccessControlEnabled), } as Omit; const internalApi: DashboardInternalApi = { diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/load_dashboard_api/get_user_access_control_data.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/load_dashboard_api/get_user_access_control_data.ts new file mode 100644 index 0000000000000..7937510442f98 --- /dev/null +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/load_dashboard_api/get_user_access_control_data.ts @@ -0,0 +1,30 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { DASHBOARD_SAVED_OBJECT_TYPE } from '../../../common/constants'; +import { getAccessControlClient } from '../../services/access_control_service'; +import { coreServices } from '../../services/kibana_services'; + +export const getUserAccessControlData = async () => { + try { + const accessControlClient = getAccessControlClient(); + const currentUser = await coreServices?.userProfile.getCurrent(); + const { isGloballyAuthorized } = await accessControlClient.checkGlobalPrivilege( + DASHBOARD_SAVED_OBJECT_TYPE + ); + + if (!currentUser) { + return; + } + + return { uid: currentUser.uid, hasGlobalAccessControlPrivilege: isGloballyAuthorized }; + } catch (error) { + return; + } +}; diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/load_dashboard_api/load_dashboard_api.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/load_dashboard_api/load_dashboard_api.ts index 2af4698f963ba..3276d5dbc743f 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/load_dashboard_api/load_dashboard_api.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/load_dashboard_api/load_dashboard_api.ts @@ -8,6 +8,7 @@ */ import { ContentInsightsClient } from '@kbn/content-management-content-insights-public'; +import { getAccessControlClient } from '../../services/access_control_service'; import { getDashboardBackupService } from '../../services/dashboard_backup_service'; import { coreServices } from '../../services/kibana_services'; import { logger } from '../../services/logger'; @@ -15,6 +16,7 @@ import { getDashboardApi } from '../get_dashboard_api'; import { startQueryPerformanceTracking } from '../performance/query_performance_tracking'; import type { DashboardCreationOptions } from '../types'; import { transformPanels } from './transform_panels'; +import { getUserAccessControlData } from './get_user_access_control_data'; import { dashboardClient } from '../../dashboard_client'; import { DEFAULT_DASHBOARD_STATE } from '../default_dashboard_state'; import { DASHBOARD_DURATION_START_MARK } from '../performance/dashboard_duration_start_mark'; @@ -28,7 +30,13 @@ export async function loadDashboardApi({ }) { const creationOptions = await getCreationOptions?.(); const incomingEmbeddables = creationOptions?.getIncomingEmbeddables?.(); - const readResult = savedObjectId ? await dashboardClient.get(savedObjectId) : undefined; + const [readResult, user, isAccessControlEnabled] = savedObjectId + ? await Promise.all([ + dashboardClient.get(savedObjectId), + getUserAccessControlData(), + getAccessControlClient().isAccessControlEnabled(), + ]) + : [undefined, undefined, undefined]; const validationResult = readResult && creationOptions?.validateLoadedSavedObject?.(readResult); if (validationResult === 'invalid') { @@ -63,6 +71,8 @@ export async function loadDashboardApi({ }, readResult, savedObjectId, + user, + isAccessControlEnabled, }); const performanceSubscription = startQueryPerformanceTracking(api, { diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/save_modal/open_save_modal.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_api/save_modal/open_save_modal.tsx index e2a14f707c46b..6258bf19ae814 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/save_modal/open_save_modal.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/save_modal/open_save_modal.tsx @@ -13,6 +13,7 @@ import type { Reference } from '@kbn/content-management-utils'; import { reportPerformanceMetricEvent } from '@kbn/ebt-tools'; import { showSaveModal } from '@kbn/saved-objects-plugin/public'; import { i18n } from '@kbn/i18n'; +import type { SavedObjectAccessControl } from '@kbn/core-saved-objects-common'; import type { DashboardSaveOptions, SaveDashboardReturn } from './types'; import { coreServices, @@ -43,6 +44,7 @@ export async function openSaveModal({ projectRoutingRestore, title, viewMode, + accessControl, }: { description?: string; isManaged: boolean; @@ -55,6 +57,7 @@ export async function openSaveModal({ projectRoutingRestore: boolean; title: string; viewMode: ViewMode; + accessControl?: Partial; }) { try { if (viewMode === 'edit' && isManaged) { @@ -69,6 +72,7 @@ export async function openSaveModal({ newDescription, newCopyOnSave, newTimeRestore, + newAccessMode, newProjectRoutingRestore, onTitleDuplicate, isTitleDuplicateConfirmed, @@ -114,6 +118,8 @@ export async function openSaveModal({ saveOptions, dashboardState: dashboardStateToSave, lastSavedId, + // Only pass access mode for new dashboard creation (no lastSavedId) + accessMode: !lastSavedId && newAccessMode ? newAccessMode : undefined, }); const addDuration = window.performance.now() - beforeAddTime; @@ -148,7 +154,9 @@ export async function openSaveModal({ description={description ?? ''} showCopyOnSave={false} onSave={onSaveAttempt} + accessControl={accessControl} customModalTitle={getCustomModalTitle(viewMode)} + isDuplicateAction={Boolean(lastSavedId)} /> ); } diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/save_modal/save_dashboard.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/save_modal/save_dashboard.ts index 5918955526907..1bbc294cff222 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/save_modal/save_dashboard.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/save_modal/save_dashboard.ts @@ -18,16 +18,14 @@ export const saveDashboard = async ({ saveOptions, dashboardState, references, + accessMode, }: SaveDashboardProps): Promise => { - /** - * Save the saved object using the content management - */ const idToSaveTo = saveOptions.saveAsCopy ? undefined : lastSavedId; try { const result = idToSaveTo ? await dashboardClient.update(idToSaveTo, dashboardState, references) - : await dashboardClient.create(dashboardState, references); + : await dashboardClient.create(dashboardState, references, accessMode); const newId = result.id; diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/save_modal/save_modal.test.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_api/save_modal/save_modal.test.tsx index e81ad8815445c..0d337b7c3b4be 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/save_modal/save_modal.test.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/save_modal/save_modal.test.tsx @@ -10,6 +10,11 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { I18nProvider } from '@kbn/i18n-react'; +import { DashboardSaveModal } from './save_modal'; + +jest.mock('@kbn/content-management-access-control-public', () => ({ + AccessModeContainer: () => null, +})); jest.mock('@kbn/saved-objects-plugin/public', () => ({ SavedObjectSaveModal: () => null, @@ -25,7 +30,33 @@ jest.mock('@kbn/saved-objects-plugin/public', () => ({ ), })); -import { DashboardSaveModal } from './save_modal'; +jest.mock('../../services/kibana_services', () => ({ + coreServices: { + userProfile: { + getCurrent: jest.fn(), + }, + }, + savedObjectsTaggingService: undefined, + spacesService: { + getActiveSpace: jest.fn().mockResolvedValue({ + id: 'default', + name: 'Default', + disabledFeatures: [], + }), + }, +})); + +jest.mock('../../services/access_control_service', () => ({ + getAccessControlClient: jest.fn().mockReturnValue({ + isInEditAccessMode: jest.fn().mockReturnValue(false), + getCapabilities: jest.fn().mockResolvedValue({ + capabilities: { + createAccessMode: true, + createReadOnlyAccessMode: true, + }, + }), + }), +})); const mockSave = jest.fn(); const mockClose = jest.fn(); diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/save_modal/save_modal.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_api/save_modal/save_modal.tsx index ad78cca17c8ed..78a90d62f4387 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/save_modal/save_modal.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/save_modal/save_modal.tsx @@ -9,12 +9,27 @@ import React, { Fragment, useCallback } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiIconTip, EuiSwitch } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIconTip, + EuiSpacer, + EuiSwitch, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import type { SaveResult } from '@kbn/saved-objects-plugin/public'; import { SavedObjectSaveModalWithSaveResult } from '@kbn/saved-objects-plugin/public'; -import { savedObjectsTaggingService } from '../../services/kibana_services'; +import { AccessModeContainer } from '@kbn/content-management-access-control-public'; +import type { SavedObjectAccessControl } from '@kbn/core-saved-objects-common'; +import { DASHBOARD_SAVED_OBJECT_TYPE } from '@kbn/deeplinks-analytics/constants'; +import { getAccessControlClient } from '../../services/access_control_service'; +import { + coreServices, + savedObjectsTaggingService, + spacesService, +} from '../../services/kibana_services'; import type { DashboardSaveOptions } from './types'; interface DashboardSaveModalProps { @@ -26,6 +41,7 @@ interface DashboardSaveModalProps { newTimeRestore, newProjectRoutingRestore, isTitleDuplicateConfirmed, + newAccessMode, onTitleDuplicate, }: DashboardSaveOptions) => Promise; onClose: () => void; @@ -38,6 +54,8 @@ interface DashboardSaveModalProps { showStoreTimeOnSave?: boolean; showStoreProjectRoutingOnSave?: boolean; customModalTitle?: string; + accessControl?: Partial; + isDuplicateAction?: boolean; } type SaveDashboardHandler = (args: { @@ -60,11 +78,16 @@ export const DashboardSaveModal: React.FC = ({ title, timeRestore, projectRoutingRestore, + accessControl, + isDuplicateAction, }) => { const [selectedTags, setSelectedTags] = React.useState(tags ?? []); const [persistSelectedTimeInterval, setPersistSelectedTimeInterval] = React.useState(timeRestore); const [persistSelectedProjectRouting, setPersistSelectedProjectRouting] = React.useState(projectRoutingRestore); + const [selectedAccessMode, setSelectedAccessMode] = React.useState( + accessControl?.accessMode ?? 'default' + ); const saveDashboard = React.useCallback( async ({ @@ -83,8 +106,15 @@ export const DashboardSaveModal: React.FC = ({ isTitleDuplicateConfirmed, onTitleDuplicate, newTags: selectedTags, + newAccessMode: selectedAccessMode, }), - [onSave, persistSelectedTimeInterval, persistSelectedProjectRouting, selectedTags] + [ + onSave, + persistSelectedTimeInterval, + persistSelectedProjectRouting, + selectedTags, + selectedAccessMode, + ] ); const renderDashboardSaveOptions = useCallback(() => { @@ -166,6 +196,19 @@ export const DashboardSaveModal: React.FC = ({ ) : null} + {!isDuplicateAction && ( + <> + + + + )} ); }, [ @@ -174,6 +217,8 @@ export const DashboardSaveModal: React.FC = ({ selectedTags, showStoreTimeOnSave, showStoreProjectRoutingOnSave, + accessControl, + isDuplicateAction, ]); return ( diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/save_modal/types.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/save_modal/types.ts index 41e900738c4d0..977b187843378 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/save_modal/types.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/save_modal/types.ts @@ -9,6 +9,7 @@ import type { Reference } from '@kbn/content-management-utils'; import type { SavedObjectSaveOpts } from '@kbn/saved-objects-plugin/public'; +import type { SavedObjectAccessControl } from '@kbn/core-saved-objects-common'; import type { DashboardState } from '../../../common'; export interface DashboardSaveOptions { @@ -17,6 +18,7 @@ export interface DashboardSaveOptions { newDescription: string; newCopyOnSave: boolean; newTimeRestore: boolean; + newAccessMode?: SavedObjectAccessControl['accessMode']; newProjectRoutingRestore: boolean; onTitleDuplicate: () => void; isTitleDuplicateConfirmed: boolean; @@ -30,6 +32,7 @@ export interface SaveDashboardProps { saveOptions: SavedDashboardSaveOpts; searchSourceReferences?: Reference[]; lastSavedId?: string; + accessMode?: SavedObjectAccessControl['accessMode']; } export interface SaveDashboardReturn { diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts index aa74c43cf1748..f48dec2f00a52 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts @@ -51,6 +51,7 @@ import { type TracksOverlays } from '@kbn/presentation-util'; import type { ControlsGroupState } from '@kbn/controls-schemas'; import type { LocatorPublic } from '@kbn/share-plugin/common'; import type { BehaviorSubject, Observable, Subject } from 'rxjs'; +import type { SavedObjectAccessControl } from '@kbn/core-saved-objects-common'; import type { DashboardLocatorParams } from '../../common'; import type { DashboardReadResponseBody, DashboardState, GridData } from '../../server'; import type { SaveDashboardReturn } from './save_modal/types'; @@ -161,6 +162,11 @@ export type DashboardApi = CanExpandPanels & setTags: (tags: string[]) => void; setTimeRange: (timeRange?: TimeRange | undefined) => void; unifiedSearchFilters$: PublishesUnifiedSearch['filters$']; + accessControl$: PublishingSubject>; + changeAccessMode: (accessMode: SavedObjectAccessControl['accessMode']) => Promise; + createdBy?: string; + user?: DashboardUser; + isAccessControlEnabled?: boolean; }; export interface DashboardInternalApi { @@ -177,3 +183,8 @@ export interface DashboardInternalApi { controlGroupReferences: Reference[]; }; } + +export interface DashboardUser { + uid: string; + hasGlobalAccessControlPrivilege: boolean; +} diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/view_mode_manager.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/view_mode_manager.ts index ed80944b4a167..a0d04805b9579 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/view_mode_manager.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/view_mode_manager.ts @@ -10,6 +10,9 @@ import type { EmbeddablePackageState } from '@kbn/embeddable-plugin/public'; import type { ViewMode } from '@kbn/presentation-publishing'; import { BehaviorSubject } from 'rxjs'; +import type { SavedObjectAccessControl } from '@kbn/core-saved-objects-common'; +import type { DashboardUser } from './types'; +import { getAccessControlClient } from '../services/access_control_service'; import { getDashboardBackupService } from '../services/dashboard_backup_service'; import { getDashboardCapabilities } from '../utils/get_dashboard_capabilities'; @@ -17,14 +20,34 @@ export function initializeViewModeManager({ incomingEmbeddables, isManaged, savedObjectId, + accessControl, + createdBy, + user, }: { incomingEmbeddables?: EmbeddablePackageState[]; isManaged: boolean; savedObjectId?: string; + accessControl?: Partial; + createdBy?: string; + user?: DashboardUser; }) { const dashboardBackupService = getDashboardBackupService(); + const accessControlClient = getAccessControlClient(); + + const isDashboardInEditAccessMode = accessControlClient.isInEditAccessMode(accessControl); + + const canUserManageAccessControl = + user?.hasGlobalAccessControlPrivilege || + accessControlClient.checkUserAccessControl({ + accessControl, + createdBy, + userId: user?.uid, + }); + + const canUserEditDashboard = isDashboardInEditAccessMode || canUserManageAccessControl; + function getInitialViewMode() { - if (isManaged || !getDashboardCapabilities().showWriteControls) { + if (isManaged || !getDashboardCapabilities().showWriteControls || !canUserEditDashboard) { return 'view'; } diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_app/_dashboard_app_strings.ts b/src/platform/plugins/shared/dashboard/public/dashboard_app/_dashboard_app_strings.ts index 0caf254d81500..9cbb0ef6356f6 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_app/_dashboard_app_strings.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_app/_dashboard_app_strings.ts @@ -22,7 +22,8 @@ export const dashboardReadonlyBadge = { }), getTooltip: () => i18n.translate('dashboard.badge.readOnly.tooltip', { - defaultMessage: 'Unable to save dashboards', + defaultMessage: + "You don't have permissions to edit this dashboard. Contact your admin to change your role.", }), }; @@ -146,6 +147,21 @@ export const shareModalStrings = { 'This dashboard has unsaved changes. Consider saving your dashboard before generating the {shareType}.', values: { shareType: shareType === 'embed' ? 'embed code' : 'link' }, }), + accessModeUpdateSuccess: i18n.translate('dashboard.share.changeAccessMode.success.title', { + defaultMessage: 'Permissions updated.', + }), + accessModeUpdateError: i18n.translate('dashboard.share.changeAccessMode.error.title', { + defaultMessage: 'Failed to update permissions.', + }), + draftModeCalloutTitle: i18n.translate('dashboard.share.shareModal.draftModeCallout.title', { + defaultMessage: 'Dashboard has unsaved changes', + }), + draftModeSaveButtonLabel: i18n.translate( + 'dashboard.share.shareModal.draftModeCallout.saveButton', + { + defaultMessage: 'Save', + } + ), }; /* @@ -181,6 +197,13 @@ export const topNavStrings = { description: i18n.translate('dashboard.topNave.editConfigDescription', { defaultMessage: 'Switch to edit mode', }), + writeRestrictedTooltip: i18n.translate('dashboard.topNave.editButtonTooltip.writeRestricted', { + defaultMessage: + "You don't have permission to edit this dashboard. Contact the owner to change it.", + }), + managedDashboardTooltip: i18n.translate('dashboard.editButtonTooltip.managed', { + defaultMessage: 'This dashboard is managed by Elastic. Duplicate it to make changes.', + }), }, quickSave: { label: i18n.translate('dashboard.topNave.saveButtonAriaLabel', { @@ -229,6 +252,21 @@ export const topNavStrings = { description: i18n.translate('dashboard.topNave.shareConfigDescription', { defaultMessage: 'Share Dashboard', }), + tooltipTitle: i18n.translate('dashboard.topNave.shareTooltipTitle', { + defaultMessage: 'Share', + }), + writeRestrictedModeTooltipContent: i18n.translate( + 'dashboard.topNave.shareTooltipContent.writeRestricted', + { + defaultMessage: 'Everybody in this space can view', + } + ), + editModeTooltipContent: i18n.translate( + 'dashboard.topNave.shareButtonEditModeTooltipContent.editable', + { + defaultMessage: 'Everybody in this space can edit', + } + ), }, settings: { label: i18n.translate('dashboard.topNave.settingsButtonAriaLabel', { @@ -301,3 +339,22 @@ export const getAddTimeSliderControlButtonTitle = () => i18n.translate('dashboard.editingToolbar.addTimeSliderControlButtonTitle', { defaultMessage: 'Time slider control', }); + +export const contentEditorFlyoutStrings = { + readonlyReason: { + accessControl: i18n.translate('dashboard.contentEditorFlyout.readonlyReason.accessControl', { + defaultMessage: + "You don't have permissions to edit this dashboard. Contact the owner to change it.", + }), + missingPrivileges: i18n.translate( + 'dashboard.contentEditorFlyout.readonlyReason.missingPrivileges', + { + defaultMessage: + "You don't have permissions to edit this dashboard. Contact your admin to change your role.", + } + ), + managedEntity: i18n.translate('dashboard.contentEditorFlyout.readonlyReason.managedEntity', { + defaultMessage: 'This dashboard is managed by Elastic. Duplicate it to make changes.', + }), + }, +}; diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/share/show_share_modal.test.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/share/show_share_modal.test.tsx index 549306f26e2a7..e8c16a8169cde 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/share/show_share_modal.test.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/share/show_share_modal.test.tsx @@ -12,6 +12,8 @@ import type { DashboardLocatorParams } from '../../../../common/types'; import { getDashboardBackupService } from '../../../services/dashboard_backup_service'; import { shareService } from '../../../services/kibana_services'; import { showPublicUrlSwitch, ShowShareModal } from './show_share_modal'; +import type { AccessControlClient } from '@kbn/content-management-access-control-public'; +import type { SavedObjectAccessControl } from '@kbn/core/server'; describe('showPublicUrlSwitch', () => { test('returns false if "dashboard_v2" app is not available', () => { @@ -68,6 +70,14 @@ describe('ShowShareModal', () => { const defaultShareModalProps = { isDirty: true, anchorElement: document.createElement('div'), + canSave: true, + saveDashboard: jest.fn(), + changeAccessMode: jest.fn(), + accessControlClient: {} as AccessControlClient, + accessControl: {} as SavedObjectAccessControl, + isManaged: false, + getCurrentUser: jest.fn().mockResolvedValue({} as { uid: string }), + getActiveSpace: jest.fn().mockResolvedValue({ name: 'default' }), }; it('locatorParams is missing all unsaved state when none is given', () => { diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx index 3ddcefe84d1a0..2115958f50844 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx @@ -11,8 +11,7 @@ import { omit } from 'lodash'; import moment from 'moment'; import type { ReactElement } from 'react'; import React, { useState } from 'react'; - -import { EuiCheckboxGroup } from '@elastic/eui'; +import { EuiCheckbox, EuiFlexGrid, EuiFlexItem, EuiFormFieldset } from '@elastic/eui'; import type { Capabilities } from '@kbn/core/public'; import type { QueryState } from '@kbn/data-plugin/common'; import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics'; @@ -20,9 +19,21 @@ import { i18n } from '@kbn/i18n'; import { getStateFromKbnUrl, setStateToKbnUrl, unhashUrl } from '@kbn/kibana-utils-plugin/public'; import type { LocatorPublic } from '@kbn/share-plugin/common'; +import type { SavedObjectAccessControl } from '@kbn/core-saved-objects-common'; +import { + AccessModeContainer, + type AccessControlClient, +} from '@kbn/content-management-access-control-public'; + +import { DASHBOARD_SAVED_OBJECT_TYPE } from '@kbn/deeplinks-analytics/constants'; import type { DashboardLocatorParams } from '../../../../common'; import { getDashboardBackupService } from '../../../services/dashboard_backup_service'; -import { dataService, shareService } from '../../../services/kibana_services'; +import { + dataService, + shareService, + coreServices, + spacesService, +} from '../../../services/kibana_services'; import { getDashboardCapabilities } from '../../../utils/get_dashboard_capabilities'; import { DASHBOARD_STATE_STORAGE_KEY } from '../../../utils/urls'; import { shareModalStrings } from '../../_dashboard_app_strings'; @@ -36,6 +47,13 @@ export interface ShowShareModalProps { savedObjectId?: string; dashboardTitle?: string; anchorElement: HTMLElement; + canSave: boolean; + accessControl?: Partial; + createdBy?: string; + isManaged: boolean; + accessControlClient: AccessControlClient; + saveDashboard: () => Promise; + changeAccessMode: (accessMode: SavedObjectAccessControl['accessMode']) => Promise; } export const showPublicUrlSwitch = (anonymousUserCapabilities: Capabilities) => { @@ -52,9 +70,27 @@ export function ShowShareModal({ anchorElement, savedObjectId, dashboardTitle, + canSave, + accessControl, + createdBy, + isManaged, + accessControlClient, + saveDashboard, + changeAccessMode, }: ShowShareModalProps) { if (!shareService) return; + const handleChangeAccessMode = async (accessMode: SavedObjectAccessControl['accessMode']) => { + if (!savedObjectId) return; + + try { + await changeAccessMode(accessMode); + return shareModalStrings.accessModeUpdateSuccess; + } catch (error) { + return shareModalStrings.accessModeUpdateError; + } + }; + const EmbedUrlParamExtension = ({ setParamValue, }: { @@ -69,14 +105,15 @@ export function ShowShareModal({ id: dashboardUrlParams.showTopMenu, label: shareModalStrings.getTopMenuCheckbox(), }, - { - id: dashboardUrlParams.showQueryInput, - label: shareModalStrings.getQueryCheckbox(), - }, { id: dashboardUrlParams.showTimeFilter, label: shareModalStrings.getTimeFilterCheckbox(), }, + { + id: dashboardUrlParams.showQueryInput, + label: shareModalStrings.getQueryCheckbox(), + }, + { id: showFilterBarId, label: shareModalStrings.getFilterBarCheckbox(), @@ -100,15 +137,20 @@ export function ShowShareModal({ }; return ( - + + + {checkboxes.map(({ id, label }) => ( + + handleChange(id)} + /> + + ))} + + ); }; @@ -147,7 +189,9 @@ export function ShowShareModal({ unhashUrl(baseUrl) ); - const allowShortUrl = getDashboardCapabilities().createShortUrl; + const { createShortUrl, showWriteControls } = getDashboardCapabilities(); + const allowShortUrl = createShortUrl; + const showAccessContainer = savedObjectId && !isManaged && showWriteControls; shareService.toggleShareContextMenu({ isDirty, @@ -157,9 +201,10 @@ export function ShowShareModal({ asExport, objectId: savedObjectId, objectType: 'dashboard', + onSave: canSave ? saveDashboard : undefined, objectTypeMeta: { title: i18n.translate('dashboard.share.shareModal.title', { - defaultMessage: 'Share this dashboard', + defaultMessage: 'Share dashboard', }), config: { link: { @@ -210,6 +255,17 @@ export function ShowShareModal({ id: DASHBOARD_APP_LOCATOR, params: locatorParams, }, + accessModeContainer: showAccessContainer ? ( + + ) : undefined, }, shareableUrlLocatorParams: { locator: shareService.url.locators.get( diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx index 6c2a9635909a0..ae2e57d583f4e 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx @@ -15,6 +15,7 @@ import type { TopNavMenuData } from '@kbn/navigation-plugin/public'; import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; import useObservable from 'react-use/lib/useObservable'; +import { getAccessControlClient } from '../../services/access_control_service'; import { UI_SETTINGS } from '../../../common/constants'; import { useDashboardApi } from '../../dashboard_api/use_dashboard_api'; import { confirmDiscardUnsavedChanges } from '../../dashboard_listing/confirm_overlays'; @@ -40,53 +41,40 @@ export const useDashboardMenuItems = ({ showResetChange?: boolean; }) => { const isMounted = useMountedState(); + const accessControlClient = getAccessControlClient(); const appId = useObservable(coreServices.application.currentAppId$); const [isSaveInProgress, setIsSaveInProgress] = useState(false); const dashboardApi = useDashboardApi(); - const [dashboardTitle, hasOverlays, hasUnsavedChanges, lastSavedId, viewMode] = + const [dashboardTitle, hasOverlays, hasUnsavedChanges, lastSavedId, viewMode, accessControl] = useBatchedPublishingSubjects( dashboardApi.title$, dashboardApi.hasOverlays$, dashboardApi.hasUnsavedChanges$, dashboardApi.savedObjectId$, - dashboardApi.viewMode$ + dashboardApi.viewMode$, + dashboardApi.accessControl$ ); + const disableTopNav = isSaveInProgress || hasOverlays; + const isInEditAccessMode = accessControlClient.isInEditAccessMode(accessControl); + const canManageAccessControl = useMemo(() => { + const userAccessControl = accessControlClient.checkUserAccessControl({ + accessControl, + createdBy: dashboardApi.createdBy, + userId: dashboardApi.user?.uid, + }); + return dashboardApi?.user?.hasGlobalAccessControlPrivilege || userAccessControl; + }, [accessControl, accessControlClient, dashboardApi.createdBy, dashboardApi.user]); const isCreatingNewDashboard = viewMode === 'edit' && !lastSavedId; - /** - * Show the Dashboard app's share menu - */ - const showShare = useCallback( - (anchorElement: HTMLElement, asExport?: boolean) => { - ShowShareModal({ - asExport, - dashboardTitle, - anchorElement, - savedObjectId: lastSavedId, - isDirty: Boolean(hasUnsavedChanges), - }); - }, - [dashboardTitle, hasUnsavedChanges, lastSavedId] - ); - - /** - * Save the dashboard without any UI or popups. - */ - const quickSaveDashboard = useCallback(() => { - setIsSaveInProgress(true); - dashboardApi.runQuickSave().then(() => setTimeout(() => setIsSaveInProgress(false), 100)); - }, [dashboardApi]); - - /** - * initiate interactive dashboard copy action - */ - const dashboardInteractiveSave = useCallback(() => { - dashboardApi.runInteractiveSave().then((result) => maybeRedirect(result)); - }, [maybeRedirect, dashboardApi]); + const isEditButtonDisabled = useMemo(() => { + if (disableTopNav) return true; + if (canManageAccessControl) return false; + return !isInEditAccessMode; + }, [disableTopNav, isInEditAccessMode, canManageAccessControl]); /** * Show the dashboard's "Confirm reset changes" modal. If confirmed: @@ -94,6 +82,22 @@ export const useDashboardMenuItems = ({ * (2) if `switchToViewMode` is `true`, set the dashboard to view mode. */ const [isResetting, setIsResetting] = useState(false); + + const isQuickSaveButtonDisabled = useMemo(() => { + if (disableTopNav || isResetting) return true; + if (dashboardApi.isAccessControlEnabled) { + if (canManageAccessControl) return false; + return !isInEditAccessMode; + } + return false; + }, [ + canManageAccessControl, + isInEditAccessMode, + isResetting, + dashboardApi.isAccessControlEnabled, + disableTopNav, + ]); + const resetChanges = useCallback( (switchToViewMode: boolean = false) => { dashboardApi.clearOverlays(); @@ -119,6 +123,89 @@ export const useDashboardMenuItems = ({ [dashboardApi, hasUnsavedChanges, viewMode, isMounted] ); + /** + * initiate interactive dashboard copy action + */ + const dashboardInteractiveSave = useCallback(async () => { + const result = await dashboardApi.runInteractiveSave(); + maybeRedirect(result); + if (result && !result.error) { + return result; + } + }, [maybeRedirect, dashboardApi]); + + /** + * Save the dashboard without any UI or popups. + */ + const quickSaveDashboard = useCallback(() => { + setIsSaveInProgress(true); + dashboardApi.runQuickSave().then(() => + setTimeout(() => { + setIsSaveInProgress(false); + }, 100) + ); + }, [dashboardApi]); + + const saveFromShareModal = useCallback(async () => { + if (lastSavedId) { + quickSaveDashboard(); + } else { + dashboardInteractiveSave(); + } + }, [quickSaveDashboard, dashboardInteractiveSave, lastSavedId]); + + /** + * Show the Dashboard app's share menu + */ + const showShare = useCallback( + (anchorElement: HTMLElement, asExport?: boolean) => { + ShowShareModal({ + asExport, + dashboardTitle, + anchorElement, + savedObjectId: lastSavedId, + isDirty: Boolean(hasUnsavedChanges), + canSave: (canManageAccessControl || isInEditAccessMode) && Boolean(hasUnsavedChanges), + accessControl, + createdBy: dashboardApi.createdBy, + isManaged: dashboardApi.isManaged, + accessControlClient, + saveDashboard: saveFromShareModal, + changeAccessMode: dashboardApi.changeAccessMode, + }); + }, + [ + dashboardTitle, + hasUnsavedChanges, + lastSavedId, + isInEditAccessMode, + canManageAccessControl, + accessControl, + saveFromShareModal, + dashboardApi.changeAccessMode, + dashboardApi.createdBy, + accessControlClient, + dashboardApi.isManaged, + ] + ); + + const getEditTooltip = useCallback(() => { + if (dashboardApi.isManaged) { + return topNavStrings.edit.managedDashboardTooltip; + } + if (isInEditAccessMode || canManageAccessControl) { + return undefined; + } + return topNavStrings.edit.writeRestrictedTooltip; + }, [isInEditAccessMode, canManageAccessControl, dashboardApi.isManaged]); + + const getShareTooltip = useCallback(() => { + if (!dashboardApi.isAccessControlEnabled) return undefined; + return isInEditAccessMode + ? topNavStrings.share.editModeTooltipContent + : topNavStrings.share.writeRestrictedModeTooltipContent; + }, [isInEditAccessMode, dashboardApi.isAccessControlEnabled]); + /** * Register all of the top nav configs that can be used by dashboard. */ @@ -152,7 +239,8 @@ export const useDashboardMenuItems = ({ dashboardApi.setViewMode('edit'); dashboardApi.clearOverlays(); }, - disableButton: disableTopNav, + disableButton: isEditButtonDisabled, + tooltip: getEditTooltip(), } as TopNavMenuData, quickSave: { @@ -162,7 +250,7 @@ export const useDashboardMenuItems = ({ emphasize: true, fill: true, testId: 'dashboardQuickSaveMenuItem', - disableButton: disableTopNav || isResetting, + disableButton: isQuickSaveButtonDisabled, run: () => quickSaveDashboard(), splitButtonProps: { run: (anchorElement: HTMLElement) => { @@ -231,6 +319,7 @@ export const useDashboardMenuItems = ({ testId: 'shareTopNavButton', disableButton: disableTopNav, run: showShare, + tooltip: getShareTooltip(), } as TopNavMenuData, export: { @@ -270,7 +359,6 @@ export const useDashboardMenuItems = ({ }, [ disableTopNav, isSaveInProgress, - hasUnsavedChanges, isCreatingNewDashboard, lastSavedId, dashboardInteractiveSave, @@ -282,7 +370,12 @@ export const useDashboardMenuItems = ({ quickSaveDashboard, resetChanges, isResetting, + isEditButtonDisabled, + getEditTooltip, + getShareTooltip, appId, + isQuickSaveButtonDisabled, + hasUnsavedChanges, ]); const resetChangesMenuItem = useMemo(() => { diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_client/dashboard_client.ts b/src/platform/plugins/shared/dashboard/public/dashboard_client/dashboard_client.ts index 56422bc092b08..4be5392bc4c57 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_client/dashboard_client.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_client/dashboard_client.ts @@ -11,6 +11,7 @@ import { LRUCache } from 'lru-cache'; import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/public'; import type { DeleteResult } from '@kbn/content-management-plugin/common'; import type { Reference } from '@kbn/content-management-utils'; +import type { SavedObjectAccessControl } from '@kbn/core-saved-objects-common'; import type { DashboardSearchRequestBody, DashboardSearchResponseBody } from '../../server'; import { DASHBOARD_API_PATH, @@ -34,7 +35,11 @@ const cache = new LRUCache({ }); export const dashboardClient = { - create: async (dashboardState: DashboardState, references: Reference[]) => { + create: async ( + dashboardState: DashboardState, + references: Reference[], + accessMode?: SavedObjectAccessControl['accessMode'] + ) => { return coreServices.http.post(DASHBOARD_API_PATH, { version: DASHBOARD_API_VERSION, query: { @@ -43,6 +48,7 @@ export const dashboardClient = { body: JSON.stringify({ data: { ...dashboardState, + ...(accessMode && { access_control: { access_mode: accessMode } }), references, }, }), diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_listing/dashboard_unsaved_listing.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_listing/dashboard_unsaved_listing.tsx index 8daee04e8224f..15764457e43a4 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_listing/dashboard_unsaved_listing.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_listing/dashboard_unsaved_listing.tsx @@ -79,7 +79,7 @@ const DashboardUnsavedItem = ({ }) => { const styles = useMemoCss(unsavedItemStyles); return ( -
+
diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.test.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.test.tsx index b6fe1d1b90b65..1499721d7955f 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.test.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.test.tsx @@ -159,11 +159,11 @@ describe('useDashboardListingTable', () => { urlStateEnabled: false, contentEditor: { onSave: expect.any(Function), - isReadonly: false, customValidators: expect.any(Object), }, createdByEnabled: true, recentlyAccessed: expect.objectContaining({ get: expect.any(Function) }), + rowItemActions: expect.any(Function), }; expect(tableListViewTableProps).toEqual(expectedProps); @@ -278,4 +278,109 @@ describe('useDashboardListingTable', () => { expect(result.current.tableListViewTableProps.editItem).toBeUndefined(); }); + + describe('rowItemActions', () => { + beforeEach(() => { + coreServices.application.capabilities = { + ...coreServices.application.capabilities, + dashboard_v2: { + showWriteControls: true, + }, + }; + }); + + test('should disable edit and delete actions when showWriteControls is false', () => { + (coreServices.application.capabilities as any).dashboard_v2.showWriteControls = false; + + const { result } = renderHook(() => + useDashboardListingTable({ + getDashboardUrl, + goToDashboard, + }) + ); + + const rowItemActions = result.current.tableListViewTableProps.rowItemActions; + + const item = { + id: 'dashboard-1', + } as DashboardSavedObjectUserContent; + + const actions = rowItemActions!(item); + + expect(actions?.edit?.enabled).toBe(false); + expect(actions?.delete?.enabled).toBe(false); + expect(actions?.edit?.reason).toBeDefined(); + expect(actions?.delete?.reason).toBeDefined(); + }); + + test('should disable edit and delete actions when item is managed', () => { + const { result } = renderHook(() => + useDashboardListingTable({ + getDashboardUrl, + goToDashboard, + }) + ); + + const rowItemActions = result.current.tableListViewTableProps.rowItemActions; + + const item = { + id: 'dashboard-1', + managed: true, + } as DashboardSavedObjectUserContent; + + const actions = rowItemActions!(item); + + expect(actions?.edit?.enabled).toBe(false); + expect(actions?.delete?.enabled).toBe(false); + expect(actions?.edit?.reason).toBeDefined(); + expect(actions?.delete?.reason).toBeDefined(); + }); + + test('should disable edit and delete actions when user lacks access control and dashboard is read-only', () => { + const { result } = renderHook(() => + useDashboardListingTable({ + getDashboardUrl, + goToDashboard, + }) + ); + + const rowItemActions = result.current.tableListViewTableProps.rowItemActions; + + const item = { + id: 'dashboard-1', + canManageAccessControl: false, + accessMode: 'write_restricted', + } as DashboardSavedObjectUserContent; + + const actions = rowItemActions!(item); + + expect(actions?.edit?.enabled).toBe(false); + expect(actions?.delete?.enabled).toBe(false); + expect(actions?.edit?.reason).toBeDefined(); + expect(actions?.delete?.reason).toBeDefined(); + }); + + test('should enable edit and delete actions when conditions are met', () => { + const { result } = renderHook(() => + useDashboardListingTable({ + getDashboardUrl, + goToDashboard, + }) + ); + + const rowItemActions = result.current.tableListViewTableProps.rowItemActions; + + const item = { + id: 'dashboard-1', + canManageAccessControl: true, + } as DashboardSavedObjectUserContent; + + const actions = rowItemActions!(item); + + expect(actions?.edit?.enabled).toBe(true); + expect(actions?.delete?.enabled).toBe(true); + expect(actions?.edit?.reason).toBeUndefined(); + expect(actions?.delete?.reason).toBeUndefined(); + }); + }); }); diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.tsx index 9a52d30a39645..4efb385c19d8c 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.tsx @@ -18,15 +18,22 @@ import type { ViewMode } from '@kbn/presentation-publishing'; import { asyncMap } from '@kbn/std'; import { DASHBOARD_SAVED_OBJECT_TYPE } from '../../../common/constants'; +import { contentEditorFlyoutStrings } from '../../dashboard_app/_dashboard_app_strings'; import { - SAVED_OBJECT_DELETE_TIME, - SAVED_OBJECT_LOADED_TIME, -} from '../../utils/telemetry_constants'; + checkForDuplicateDashboardTitle, + dashboardClient, + findService, +} from '../../dashboard_client'; +import { getAccessControlClient } from '../../services/access_control_service'; import { getDashboardBackupService } from '../../services/dashboard_backup_service'; import { getDashboardRecentlyAccessedService } from '../../services/dashboard_recently_accessed_service'; import { coreServices, savedObjectsTaggingService } from '../../services/kibana_services'; import { logger } from '../../services/logger'; import { getDashboardCapabilities } from '../../utils/get_dashboard_capabilities'; +import { + SAVED_OBJECT_DELETE_TIME, + SAVED_OBJECT_LOADED_TIME, +} from '../../utils/telemetry_constants'; import { dashboardListingErrorStrings, dashboardListingTableStrings, @@ -34,11 +41,6 @@ import { import { confirmCreateWithUnsaved } from '../confirm_overlays'; import { DashboardListingEmptyPrompt } from '../dashboard_listing_empty_prompt'; import type { DashboardSavedObjectUserContent } from '../types'; -import { - checkForDuplicateDashboardTitle, - dashboardClient, - findService, -} from '../../dashboard_client'; type GetDetailViewLink = TableListViewTableProps['getDetailViewLink']; @@ -94,6 +96,8 @@ export const useDashboardListingTable = ({ dashboardBackupService.getDashboardIdsWithUnsavedChanges() ); + const accessControlClient = getAccessControlClient(); + const listingLimit = coreServices.uiSettings.get(SAVED_OBJECTS_LIMIT_SETTING); const initialPageSize = coreServices.uiSettings.get(SAVED_OBJECTS_PER_PAGE_SETTING); @@ -184,7 +188,7 @@ export const useDashboardListingTable = ({ ); const findItems = useCallback( - ( + async ( searchTerm: string, { references, @@ -196,6 +200,17 @@ export const useDashboardListingTable = ({ ) => { const searchStartTime = window.performance.now(); + const [userResponse, globalPrivilegeResponse] = await Promise.allSettled([ + coreServices.userProfile.getCurrent(), + accessControlClient.checkGlobalPrivilege(DASHBOARD_SAVED_OBJECT_TYPE), + ]); + + const userId = userResponse.status === 'fulfilled' ? userResponse.value.uid : undefined; + const isGloballyAuthorized = + globalPrivilegeResponse.status === 'fulfilled' + ? globalPrivilegeResponse.value.isGloballyAuthorized + : false; + return findService .search({ search: searchTerm, @@ -216,30 +231,43 @@ export const useDashboardListingTable = ({ }, }); const tagApi = savedObjectsTaggingService?.getTaggingApi(); + return { total, - hits: dashboards.map( - ({ id, data, meta }) => - ({ - type: 'dashboard', - id, - updatedAt: meta.updated_at!, - createdAt: meta.created_at, - createdBy: meta.created_by, - updatedBy: meta.updated_by, - references: tagApi && data.tags ? data.tags.map(tagApi.ui.tagIdToReference) : [], - managed: meta.managed, - attributes: { - title: data.title, - description: data.description, - timeRestore: Boolean(data.time_range), + hits: dashboards.map(({ id, data, meta }) => { + const canManageAccessControl = + isGloballyAuthorized || + accessControlClient.checkUserAccessControl({ + accessControl: { + owner: data?.access_control?.owner, + accessMode: data?.access_control?.access_mode, }, - } as DashboardSavedObjectUserContent) - ), + createdBy: meta.created_at, + userId, + }); + + return { + type: 'dashboard', + id, + updatedAt: meta.updated_at, + createdAt: meta.created_at, + createdBy: meta.created_by, + updatedBy: meta.updated_by, + references: tagApi && data.tags ? data.tags.map(tagApi.ui.tagIdToReference) : [], + managed: meta.managed, + attributes: { + title: data.title, + description: data.description, + timeRestore: Boolean(data.time_range), + }, + canManageAccessControl, + accessMode: data?.access_control?.access_mode, + } as DashboardSavedObjectUserContent; + }), }; }); }, - [listingLimit] + [listingLimit, accessControlClient] ); const deleteItems = useCallback( @@ -292,7 +320,6 @@ export const useDashboardListingTable = ({ const { showWriteControls } = getDashboardCapabilities(); return { contentEditor: { - isReadonly: !showWriteControls, onSave: updateItemMeta, customValidators: contentEditorValidators, }, @@ -315,6 +342,38 @@ export const useDashboardListingTable = ({ urlStateEnabled, createdByEnabled: true, recentlyAccessed: getDashboardRecentlyAccessedService(), + rowItemActions: (item) => { + const isDisabled = () => { + if (!showWriteControls) return true; + if (item?.managed === true) return true; + if (item?.canManageAccessControl === false && item?.accessMode === 'write_restricted') + return true; + return false; + }; + + const getReason = () => { + if (!showWriteControls) { + return contentEditorFlyoutStrings.readonlyReason.missingPrivileges; + } + if (item?.managed) { + return contentEditorFlyoutStrings.readonlyReason.managedEntity; + } + if (item?.canManageAccessControl === false) { + return contentEditorFlyoutStrings.readonlyReason.accessControl; + } + }; + + return { + edit: { + enabled: !isDisabled(), + reason: getReason(), + }, + delete: { + enabled: !isDisabled(), + reason: getReason(), + }, + }; + }, }; }, [ contentEditorValidators, diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_listing/types.ts b/src/platform/plugins/shared/dashboard/public/dashboard_listing/types.ts index 41db3b8caf7a3..eb0c6f9fc2513 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_listing/types.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_listing/types.ts @@ -10,6 +10,7 @@ import type { PropsWithChildren } from 'react'; import type { UserContentCommonSchema } from '@kbn/content-management-table-list-view-common'; import type { ViewMode } from '@kbn/presentation-publishing'; +import type { SavedObjectAccessControl } from '@kbn/core-saved-objects-common'; export type DashboardListingProps = PropsWithChildren<{ disableCreateDashboardButton?: boolean; @@ -28,4 +29,6 @@ export interface DashboardSavedObjectUserContent extends UserContentCommonSchema description?: string; timeRestore: boolean; }; + canManageAccessControl?: boolean; + accessMode?: SavedObjectAccessControl['accessMode']; } diff --git a/src/platform/plugins/shared/dashboard/public/services/access_control_service.ts b/src/platform/plugins/shared/dashboard/public/services/access_control_service.ts new file mode 100644 index 0000000000000..ca8d935d75786 --- /dev/null +++ b/src/platform/plugins/shared/dashboard/public/services/access_control_service.ts @@ -0,0 +1,24 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { AccessControlClient } from '@kbn/content-management-access-control-public'; +import { coreServices } from './kibana_services'; + +let accessControlClient: AccessControlClient | null = null; + +export const getAccessControlClient = () => { + if (accessControlClient) { + return accessControlClient; + } + const client = new AccessControlClient({ + http: coreServices.http, + }); + accessControlClient = client; + return client; +}; diff --git a/src/platform/plugins/shared/dashboard/server/api/create/create.ts b/src/platform/plugins/shared/dashboard/server/api/create/create.ts index 01546c914c514..e745394fd9068 100644 --- a/src/platform/plugins/shared/dashboard/server/api/create/create.ts +++ b/src/platform/plugins/shared/dashboard/server/api/create/create.ts @@ -21,16 +21,21 @@ export async function create( createBody: DashboardCreateRequestBody ): Promise { const { core } = await requestCtx.resolve(['core']); + const { access_control: accessControl, ...restOfData } = createBody.data; const { attributes: soAttributes, references: soReferences, error: transformInError, - } = transformDashboardIn(createBody.data); + } = transformDashboardIn(restOfData); if (transformInError) { throw Boom.badRequest(`Invalid data. ${transformInError.message}`); } + const supportsAccessControl = core.savedObjects.typeRegistry.supportsAccessControl( + DASHBOARD_SAVED_OBJECT_TYPE + ); + const savedObject = await core.savedObjects.client.create( DASHBOARD_SAVED_OBJECT_TYPE, soAttributes, @@ -38,6 +43,12 @@ export async function create( references: soReferences, ...(createBody.id && { id: createBody.id }), ...(createBody.spaces && { initialNamespaces: createBody.spaces }), + ...(accessControl?.access_mode && + supportsAccessControl && { + accessControl: { + accessMode: accessControl.access_mode ?? 'default', + }, + }), } ); diff --git a/src/platform/plugins/shared/dashboard/server/api/dashboard_state_schemas.ts b/src/platform/plugins/shared/dashboard/server/api/dashboard_state_schemas.ts index 87325ce805db5..6814c60ec342d 100644 --- a/src/platform/plugins/shared/dashboard/server/api/dashboard_state_schemas.ts +++ b/src/platform/plugins/shared/dashboard/server/api/dashboard_state_schemas.ts @@ -144,6 +144,15 @@ export const optionsSchema = schema.object({ ), }); +export const accessControlSchema = schema.maybe( + schema.object({ + owner: schema.maybe(schema.string()), + access_mode: schema.maybe( + schema.oneOf([schema.literal('write_restricted'), schema.literal('default')]) + ), + }) +); + export function getDashboardStateSchema() { return schema.object({ // unsuppoted "as code" keys @@ -176,5 +185,6 @@ export function getDashboardStateSchema() { ), time_range: schema.maybe(timeRangeSchema), title: schema.string({ meta: { description: 'A human-readable title for the dashboard' } }), + access_control: accessControlSchema, }); } diff --git a/src/platform/plugins/shared/dashboard/server/api/saved_object_utils.ts b/src/platform/plugins/shared/dashboard/server/api/saved_object_utils.ts index 061a54badd721..8d2a11229c178 100644 --- a/src/platform/plugins/shared/dashboard/server/api/saved_object_utils.ts +++ b/src/platform/plugins/shared/dashboard/server/api/saved_object_utils.ts @@ -56,6 +56,12 @@ export function getDashboardCRUResponseBody( id: savedObject.id, data: { ...dashboardState, + ...(savedObject?.accessControl && { + access_control: { + access_mode: savedObject.accessControl.accessMode, + owner: savedObject.accessControl.owner, + }, + }), ...(references.length && { references }), }, meta: getDashboardMeta(savedObject, operation), diff --git a/src/platform/plugins/shared/dashboard/server/api/search/schemas.ts b/src/platform/plugins/shared/dashboard/server/api/search/schemas.ts index 0322cd389ce07..731ad9e0993d2 100644 --- a/src/platform/plugins/shared/dashboard/server/api/search/schemas.ts +++ b/src/platform/plugins/shared/dashboard/server/api/search/schemas.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { timeRangeSchema } from '@kbn/es-query-server'; +import { accessControlSchema } from '../dashboard_state_schemas'; import { baseMetaSchema, createdMetaSchema, updatedMetaSchema } from '../meta_schemas'; export const searchRequestBodySchema = schema.object({ @@ -51,6 +52,7 @@ export const searchResponseBodySchema = schema.object({ tags: schema.maybe(schema.arrayOf(schema.string())), time_range: schema.maybe(timeRangeSchema), title: schema.string(), + access_control: accessControlSchema, }), meta: schema.allOf([baseMetaSchema, createdMetaSchema, updatedMetaSchema]), }) diff --git a/src/platform/plugins/shared/dashboard/server/api/search/search.ts b/src/platform/plugins/shared/dashboard/server/api/search/search.ts index 48313af834e5a..aafa9e91fe756 100644 --- a/src/platform/plugins/shared/dashboard/server/api/search/search.ts +++ b/src/platform/plugins/shared/dashboard/server/api/search/search.ts @@ -52,6 +52,12 @@ export async function search( ...(description && { description }), ...(tags && { tags }), ...(time_range && { time_range }), + ...(so?.accessControl && { + access_control: { + owner: so.accessControl.owner, + access_mode: so.accessControl.accessMode, + }, + }), title: title ?? '', }, meta: getDashboardMeta(so, 'search'), diff --git a/src/platform/plugins/shared/dashboard/server/api/update/update.ts b/src/platform/plugins/shared/dashboard/server/api/update/update.ts index 53c4cd907f876..c66b1dc7b9c5e 100644 --- a/src/platform/plugins/shared/dashboard/server/api/update/update.ts +++ b/src/platform/plugins/shared/dashboard/server/api/update/update.ts @@ -21,12 +21,13 @@ export async function update( updateBody: DashboardUpdateRequestBody ): Promise { const { core } = await requestCtx.resolve(['core']); + const { access_control: accessControl, ...restOfData } = updateBody.data; const { attributes: soAttributes, references: soReferences, error: transformInError, - } = transformDashboardIn(updateBody.data); + } = transformDashboardIn(restOfData); if (transformInError) { throw Boom.badRequest(`Invalid data. ${transformInError.message}`); } diff --git a/src/platform/plugins/shared/dashboard/server/dashboard_saved_object/dashboard_saved_object.test.ts b/src/platform/plugins/shared/dashboard/server/dashboard_saved_object/dashboard_saved_object.test.ts index 8d717773a96e7..3f825ffeed10b 100644 --- a/src/platform/plugins/shared/dashboard/server/dashboard_saved_object/dashboard_saved_object.test.ts +++ b/src/platform/plugins/shared/dashboard/server/dashboard_saved_object/dashboard_saved_object.test.ts @@ -22,7 +22,9 @@ describe('dashboard saved object model version transformations', () => { beforeEach(() => { migrator = createModelVersionTestMigrator({ - type: createDashboardSavedObjectType({ migrationDeps: { embeddable: embeddableSetupMock } }), + type: createDashboardSavedObjectType({ + migrationDeps: { embeddable: embeddableSetupMock }, + }), }); }); diff --git a/src/platform/plugins/shared/dashboard/server/dashboard_saved_object/dashboard_saved_object.ts b/src/platform/plugins/shared/dashboard/server/dashboard_saved_object/dashboard_saved_object.ts index fd098e0185779..d37ec8c910670 100644 --- a/src/platform/plugins/shared/dashboard/server/dashboard_saved_object/dashboard_saved_object.ts +++ b/src/platform/plugins/shared/dashboard/server/dashboard_saved_object/dashboard_saved_object.ts @@ -26,6 +26,7 @@ export const createDashboardSavedObjectType = ({ name: DASHBOARD_SAVED_OBJECT_TYPE, indexPattern: ANALYTICS_SAVED_OBJECT_INDEX, hidden: false, + supportsAccessControl: true, namespaceType: 'multiple-isolated', convertToMultiNamespaceTypeVersion: '8.0.0', management: { diff --git a/src/platform/plugins/shared/dashboard/server/plugin.ts b/src/platform/plugins/shared/dashboard/server/plugin.ts index c614903373f74..5dcc57885c165 100644 --- a/src/platform/plugins/shared/dashboard/server/plugin.ts +++ b/src/platform/plugins/shared/dashboard/server/plugin.ts @@ -29,6 +29,8 @@ import type { import { registerContentInsights } from '@kbn/content-management-content-insights-server'; import type { SavedObjectTaggingStart } from '@kbn/saved-objects-tagging-plugin/server'; +import type { SecurityPluginStart } from '@kbn/security-plugin-types-server'; +import { registerAccessControl } from '@kbn/content-management-access-control-server'; import { tagSavedObjectTypeName } from '@kbn/saved-objects-tagging-plugin/common'; import { initializeDashboardTelemetryTask, @@ -61,6 +63,7 @@ export interface StartDeps { usageCollection?: UsageCollectionStart; savedObjectsTagging?: SavedObjectTaggingStart; share?: SharePluginStart; + security?: SecurityPluginStart; } export class DashboardPlugin @@ -124,6 +127,15 @@ export class DashboardPlugin registerRoutes(core.http); + void registerAccessControl({ + http: core.http, + isAccessControlEnabled: core.savedObjects.isAccessControlEnabled(), + getStartServices: () => + core.getStartServices().then(([_, { security }]) => ({ + security, + })), + }); + return {}; } diff --git a/src/platform/plugins/shared/dashboard/tsconfig.json b/src/platform/plugins/shared/dashboard/tsconfig.json index ce2bfe42ffa5b..50546f7db59ad 100644 --- a/src/platform/plugins/shared/dashboard/tsconfig.json +++ b/src/platform/plugins/shared/dashboard/tsconfig.json @@ -3,7 +3,13 @@ "compilerOptions": { "outDir": "target/types" }, - "include": ["*.ts", ".storybook/**/*.ts", "common/**/*", "public/**/*", "server/**/*"], + "include": [ + "*.ts", + ".storybook/**/*.ts", + "common/**/*", + "public/**/*", + "server/**/*", + ], "kbn_references": [ "@kbn/core", "@kbn/inspector-plugin", @@ -89,6 +95,10 @@ "@kbn/controls-schemas", "@kbn/controls-constants", "@kbn/presentation-util", + "@kbn/security-plugin-types-server", + "@kbn/core-saved-objects-common", + "@kbn/content-management-access-control-public", + "@kbn/content-management-access-control-server", "@kbn/react-query", "@kbn/core-http-server", "@kbn/core-chrome-layout-utils", diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu_data.tsx b/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu_data.tsx index 7b4033c9431ba..c71f392afeb11 100644 --- a/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu_data.tsx +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu_data.tsx @@ -23,6 +23,7 @@ export interface TopNavMenuData { className?: string; disableButton?: boolean | (() => boolean); tooltip?: string | (() => string | undefined); + tooltipTitle?: string; badge?: EuiBetaBadgeProps; emphasize?: boolean; fill?: boolean; diff --git a/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu_item.tsx b/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu_item.tsx index 94baf0aa2a579..e5e951959d884 100644 --- a/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu_item.tsx +++ b/src/platform/plugins/shared/navigation/public/top_nav_menu/top_nav_menu_item.tsx @@ -112,6 +112,8 @@ export function TopNavMenuItem(props: TopNavMenuItemProps) { ? { onClick: undefined, href: props.href, target: props.target } : {}; + const showFragment = props.disableButton || props.tooltip; + const btn = props.splitButtonProps ? ( {btn}; + return ( + + {btn} + + ); } return btn; } diff --git a/src/platform/plugins/shared/saved_objects_management/public/lib/process_import_response.test.ts b/src/platform/plugins/shared/saved_objects_management/public/lib/process_import_response.test.ts index 74861a96c84ae..a0c8722b8bfa4 100644 --- a/src/platform/plugins/shared/saved_objects_management/public/lib/process_import_response.test.ts +++ b/src/platform/plugins/shared/saved_objects_management/public/lib/process_import_response.test.ts @@ -13,6 +13,7 @@ import type { SavedObjectsImportUnknownError, SavedObjectsImportMissingReferencesError, SavedObjectsImportResponse, + SavedObjectsImportUnexpectedAccessControlMetadataError, } from '@kbn/core/public'; import { processImportResponse } from './process_import_response'; @@ -241,4 +242,110 @@ describe('processImportResponse()', () => { expect(result.status).toBe('success'); expect(result.importWarnings).toEqual(response.warnings); }); + + describe('access control', () => { + // Note: for phase 2 of write-restricted dashboards when we support importing + // access control metadata for admins + // test('objects with missing access control metadata get added to failedImports and result in success status', () => { + // const response: SavedObjectsImportResponse = { + // success: false, + // successCount: 0, + // errors: [ + // { + // type: 'a', + // id: '1', + // error: { + // type: 'missing_access_control_owner', + // } as SavedObjectsImportMissingAccessControlOwnerMetadataError, + // meta: {}, + // }, + // ], + // warnings: [], + // }; + // const result = processImportResponse(response); + // expect(result.failedImports).toMatchInlineSnapshot(` + // Array [ + // Object { + // "error": Object { + // "type": "missing_access_control_owner", + // }, + // "obj": Object { + // "id": "1", + // "meta": Object {}, + // "type": "a", + // }, + // }, + // ] + // `); + // expect(result.status).toBe('success'); + // }); + + // test(`objects that fail with 'requires_profile_id' get added to failedImports and result in success status`, () => { + // const response: SavedObjectsImportResponse = { + // success: false, + // successCount: 0, + // errors: [ + // { + // type: 'a', + // id: '1', + // error: { + // type: 'requires_profile_id', + // } as SavedObjectsImportRequiresProfileIdError, + // meta: {}, + // }, + // ], + // warnings: [], + // }; + // const result = processImportResponse(response); + // expect(result.failedImports).toMatchInlineSnapshot(` + // Array [ + // Object { + // "error": Object { + // "type": "requires_profile_id", + // }, + // "obj": Object { + // "id": "1", + // "meta": Object {}, + // "type": "a", + // }, + // }, + // ] + // `); + // expect(result.status).toBe('success'); + // }); + + test('objects with unexpected access control metadata get added to failedImports and result in success status', () => { + const response: SavedObjectsImportResponse = { + success: false, + successCount: 0, + errors: [ + { + type: 'a', + id: '1', + error: { + type: 'unexpected_access_control_metadata', + } as SavedObjectsImportUnexpectedAccessControlMetadataError, + meta: {}, + }, + ], + warnings: [], + }; + const result = processImportResponse(response); + expect(result.failedImports).toMatchInlineSnapshot(` + Array [ + Object { + "error": Object { + "type": "unexpected_access_control_metadata", + }, + "obj": Object { + "id": "1", + "meta": Object {}, + "type": "a", + }, + }, + ] + `); + expect(result.status).toBe('success'); + }); + }); }); diff --git a/src/platform/plugins/shared/saved_objects_management/public/lib/process_import_response.ts b/src/platform/plugins/shared/saved_objects_management/public/lib/process_import_response.ts index 71318952e30f3..c766a45635353 100644 --- a/src/platform/plugins/shared/saved_objects_management/public/lib/process_import_response.ts +++ b/src/platform/plugins/shared/saved_objects_management/public/lib/process_import_response.ts @@ -17,6 +17,7 @@ import type { SavedObjectsImportFailure, SavedObjectsImportSuccess, SavedObjectsImportWarning, + SavedObjectsImportUnexpectedAccessControlMetadataError, } from '@kbn/core/public'; export interface FailedImport { @@ -26,7 +27,8 @@ export interface FailedImport { | SavedObjectsImportAmbiguousConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError - | SavedObjectsImportUnknownError; + | SavedObjectsImportUnknownError + | SavedObjectsImportUnexpectedAccessControlMetadataError; } interface UnmatchedReference { diff --git a/src/platform/plugins/shared/share/public/components/share_tabs.tsx b/src/platform/plugins/shared/share/public/components/share_tabs.tsx index 006f6b3b030e5..f03962e3b5b0d 100644 --- a/src/platform/plugins/shared/share/public/components/share_tabs.tsx +++ b/src/platform/plugins/shared/share/public/components/share_tabs.tsx @@ -7,9 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useMemo, type FC } from 'react'; +import React, { type ReactNode, useMemo, type FC } from 'react'; import { TabbedModal, type IModalTabDeclaration } from '@kbn/shared-ux-tabbed-modal'; - import { ShareProvider, useShareContext, type IShareContext } from './context'; import { linkTab, embedTab } from './tabs'; @@ -25,7 +24,7 @@ export const ShareMenu: FC<{ shareContext: IShareContext }> = ({ shareContext }) export const ShareMenuTabs = () => { const shareContext = useShareContext(); - const { objectTypeMeta, onClose, shareMenuItems, anchorElement } = shareContext; + const { objectTypeMeta, onClose, shareMenuItems, anchorElement, sharingData } = shareContext; const tabs = useMemo(() => { const tabList: Array> = []; @@ -45,6 +44,8 @@ export const ShareMenuTabs = () => { return tabList; }, [objectTypeMeta, shareMenuItems]); + const showAccessModeContainer = Boolean(sharingData?.accessModeContainer); + return Boolean(tabs.length) ? ( { defaultSelectedTabId={tabs[0].id} anchorElement={anchorElement} data-test-subj="shareContextModal" + aboveTabsContent={ + showAccessModeContainer ? (sharingData?.accessModeContainer as ReactNode) : null + } outsideClickCloses /> ) : null; diff --git a/src/platform/plugins/shared/share/public/components/tabs/embed/embed_content.tsx b/src/platform/plugins/shared/share/public/components/tabs/embed/embed_content.tsx index 78bb28c2239fe..0db455fed5c5e 100644 --- a/src/platform/plugins/shared/share/public/components/tabs/embed/embed_content.tsx +++ b/src/platform/plugins/shared/share/public/components/tabs/embed/embed_content.tsx @@ -18,8 +18,8 @@ import { EuiSwitch, type EuiSwitchEvent, EuiToolTip, - EuiIconTip, copyToClipboard, + EuiIconTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -341,11 +341,12 @@ export const EmbedContent = ({ = { 'updating spaces of', 'updated spaces of', ], + saved_object_update_objects_owner: ['update owner of', 'updating owner of', 'updated owner of'], + saved_object_update_objects_access_mode: [ + 'update access mode of', + 'updating access mode of', + 'updated access mode of', + ], }; const savedObjectAuditTypes: Record> = { @@ -273,6 +279,8 @@ const savedObjectAuditTypes: Record> saved_object_remove_references: 'change', saved_object_collect_multinamespace_references: 'access', saved_object_update_objects_spaces: 'change', + saved_object_update_objects_owner: 'change', + saved_object_update_objects_access_mode: 'change', }; export function savedObjectEvent({ diff --git a/x-pack/platform/plugins/shared/security/server/saved_objects/access_control_service.md b/x-pack/platform/plugins/shared/security/server/saved_objects/access_control_service.md new file mode 100644 index 0000000000000..24cf147107753 --- /dev/null +++ b/x-pack/platform/plugins/shared/security/server/saved_objects/access_control_service.md @@ -0,0 +1,57 @@ +### Access Control Service + +In addition to Kibana’s traditional role-based access control (RBAC), certain Saved Object types now support object-level access control permissions. This allows us to enforce ownership, and custom access modes beyond the “all or read” space-level privileges. + +The Access Control Service (ACS) is responsible for evaluating these additional rules before allowing create, update, delete, or access‑control‑change operations on write‑restricted Saved Objects. + +Here’s how it works in the context of the Saved Objects Repository (SOR) security extension flow: + +When the Security Extension intercepts an operation, it delegates to ACS to determine whether the object type supports access control and if object‑level rules should be applied. The rules being: + +- Objects need to support access control, which is determined during SO registration. +- An object, when in write_restricted mode, can only be modified by the current owner or the Kibana admin. +- An object, which supports access control, but is in default accessMode can be modified by anyone who has the appropriate space-level privileges. + +ACS returns either an empty set (no further checks needed) or a list of objects requiring RBAC verification. + +This ensures that access decisions combine both access control rules (ownership, sharing, etc.) and regular RBAC privilege checks before the operation is authorized. + +The following sequence diagram shows how ACS is invoked for different scenarios: creation, modification, deletion, and access control changes, and how its results integrate with the overall authorization flow. + +```mermaid +sequenceDiagram + actor C as SavedObjectsClient + actor SOR as Repository + actor SE as Security Extension + actor ACS as Access Control Service + + + +C->>SOR: Create write restricted SO +SOR->>SE: AuthorizeCreate +SE->>ACS: Check if type supports access control +ACS->>SE: Objects requiring further checks +SE->>SE: Regular RBAC privilege checks - throws if failure +SE->>SOR: Authz Result (create if authorized) +SOR->>C: Created object + + + +C->>SOR: Update/Delete write restricted SO +SOR->>SE: calls respective authz function +SE->>SE: internal Authorize +SE->>ACS: Check access control logic (owner or admin with privilege) +ACS->>SE: List of objects requiring further RBAC checks +SE->>SE: Regular RBAC privilege checks - throws if failure +SE->>SOR: Authorization result (perform action if authorized) +SOR->>SOR: Perform action + + +C->>SOR: Change Access Control(owner or accessMode) +SOR->>SE: authorizeChangeAccessControl +SE->>ACS: Enforce accessControl logic (owner or admin with privilege) +ACS->>SE: List of objects requiring further RBAC checks +SE->>SE: Regular RBAC privilege checks - throws if failure +SE->>SOR: Authorization Result +SOR->>C: Object updated with new accessControl data +``` diff --git a/x-pack/platform/plugins/shared/security/server/saved_objects/access_control_service.test.ts b/x-pack/platform/plugins/shared/security/server/saved_objects/access_control_service.test.ts new file mode 100644 index 0000000000000..4d8615ea64cf3 --- /dev/null +++ b/x-pack/platform/plugins/shared/security/server/saved_objects/access_control_service.test.ts @@ -0,0 +1,331 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AuthenticatedUser, ISavedObjectTypeRegistry } from '@kbn/core/server'; +import { mockAuthenticatedUser } from '@kbn/core-security-common/mocks'; + +import { AccessControlService } from './access_control_service'; +import { SecurityAction } from './types'; + +describe('AccessControlService', () => { + // Mock type registry (expand to satisfy ISavedObjectTypeRegistry) + const typeRegistry = { + supportsAccessControl: (type: string) => type === 'dashboard', + } as unknown as jest.Mocked; + + // Full AuthenticatedUser mock + const makeUser = (profileUid: string | null): AuthenticatedUser | null => + profileUid + ? mockAuthenticatedUser({ + username: profileUid, + profile_uid: profileUid, + }) + : null; + + describe('#getTypesRequiringPrivilegeCheck', () => { + let service: AccessControlService; + + beforeEach(() => { + service = new AccessControlService({ typeRegistry }); + }); + + it('returns type if object is write_restricted, has owner, and user is not owner', () => { + service.setUserForOperation(makeUser('bob')); + const objects = [ + { + type: 'dashboard', + id: 'id_1', + accessControl: { accessMode: 'write_restricted' as const, owner: 'alice' }, + }, + ]; + const { types: typesRequiringAccessControl, objects: results } = + service.getObjectsRequiringPrivilegeCheck({ + objects, + actions: new Set([SecurityAction.CHANGE_OWNERSHIP]), + }); + expect(typesRequiringAccessControl.has('dashboard')).toBe(true); + expect(results).toEqual([ + { + type: 'dashboard', + id: 'id_1', + requiresManageAccessControl: true, + }, + ]); + }); + + it('does not return type if user is owner', () => { + service.setUserForOperation(makeUser('alice')); + const objects = [ + { + type: 'dashboard', + id: 'id_1', + accessControl: { accessMode: 'write_restricted' as const, owner: 'alice' }, + }, + ]; + const { types: typesRequiringAccessControl, objects: results } = + service.getObjectsRequiringPrivilegeCheck({ + objects, + actions: new Set([SecurityAction.CHANGE_OWNERSHIP]), + }); + expect(typesRequiringAccessControl.size).toBe(0); + expect(results).toEqual([ + { + type: 'dashboard', + id: 'id_1', + requiresManageAccessControl: false, + }, + ]); + }); + + it('does not return type if accessControl is missing', () => { + service.setUserForOperation(makeUser('bob')); + const objects = [ + { + id: '1', + type: 'dashboard', + }, + ]; + const { types: typesRequiringAccessControl, objects: results } = + service.getObjectsRequiringPrivilegeCheck({ + objects, + actions: new Set([SecurityAction.CHANGE_OWNERSHIP]), + }); + expect(typesRequiringAccessControl.size).toBe(0); + expect(results).toEqual([ + { + type: 'dashboard', + id: '1', + requiresManageAccessControl: false, + }, + ]); + }); + + it('does not return type if supportsAccessControl is false', () => { + service.setUserForOperation(makeUser('bob')); + const objects = [ + { + id: '1', + type: 'visualization', + accessControl: { accessMode: 'write_restricted' as const, owner: 'alice' }, + }, + ]; + const { types: typesRequiringAccessControl, objects: results } = + service.getObjectsRequiringPrivilegeCheck({ + objects, + actions: new Set([SecurityAction.CHANGE_OWNERSHIP]), + }); + expect(typesRequiringAccessControl.size).toBe(0); + expect(results).toEqual([ + { + type: 'visualization', + id: '1', + requiresManageAccessControl: false, + }, + ]); + }); + + it('returns all types that require access control when multiple objects are passed', () => { + service.setUserForOperation(makeUser('bob')); + const objects = [ + { + type: 'dashboard', + id: 'id_1', + accessControl: { accessMode: 'write_restricted' as const, owner: 'alice' }, + }, + { + type: 'dashboard', + id: 'id_2', + accessControl: { accessMode: 'write_restricted' as const, owner: 'charlie' }, + }, + { + type: 'visualization', + id: 'id_3', + accessControl: { accessMode: 'write_restricted' as const, owner: 'alice' }, + }, + { + type: 'dashboard', + id: 'id_4', + accessControl: { accessMode: 'default' as const, owner: 'alice' }, + }, + { + type: 'dashboard', + id: 'id_5', + }, + { + type: 'dashboard', + id: 'id_6', + accessControl: { accessMode: 'write_restricted' as const, owner: 'bob' }, // user is owner + }, + ]; + const { types: typesRequiringAccessControl, objects: results } = + service.getObjectsRequiringPrivilegeCheck({ + objects, + actions: new Set([SecurityAction.CHANGE_OWNERSHIP]), + }); + expect(typesRequiringAccessControl.has('dashboard')).toBe(true); + expect(typesRequiringAccessControl.has('visualization')).toBe(false); + expect(typesRequiringAccessControl.size).toBe(1); + expect(results).toEqual([ + { + id: 'id_1', + requiresManageAccessControl: true, // owned by another user + type: 'dashboard', + }, + { + id: 'id_2', + requiresManageAccessControl: true, // owned by another user + type: 'dashboard', + }, + { + id: 'id_3', + requiresManageAccessControl: false, // does not support access control + type: 'visualization', + }, + { + id: 'id_4', + requiresManageAccessControl: true, // owned by another user + type: 'dashboard', + }, + { + id: 'id_5', + requiresManageAccessControl: false, // no access control + type: 'dashboard', + }, + { + id: 'id_6', + requiresManageAccessControl: false, // user is owner + type: 'dashboard', + }, + ]); + }); + + describe('when accessMode is default', () => { + it('change ownership action returns type if user is not owner', () => { + service.setUserForOperation(makeUser('bob')); + const objects = [ + { + type: 'dashboard', + id: 'id_1', + accessControl: { accessMode: 'default' as const, owner: 'alice' }, + }, + ]; + const { types: typesRequiringAccessControl } = service.getObjectsRequiringPrivilegeCheck({ + objects, + actions: new Set([SecurityAction.CHANGE_OWNERSHIP]), + }); + expect(typesRequiringAccessControl.has('dashboard')).toBe(true); + }); + + it('change access mode action returns type if user is not owner', () => { + service.setUserForOperation(makeUser('bob')); + const objects = [ + { + type: 'dashboard', + id: 'id_1', + accessControl: { accessMode: 'default' as const, owner: 'alice' }, + }, + ]; + const { types: typesRequiringAccessControl } = service.getObjectsRequiringPrivilegeCheck({ + objects, + actions: new Set([SecurityAction.CHANGE_ACCESS_MODE]), + }); + expect(typesRequiringAccessControl.has('dashboard')).toBe(true); + }); + }); + }); + + describe('#enforceAccessControl', () => { + let service: AccessControlService; + beforeEach(() => { + service = new AccessControlService({ typeRegistry }); + }); + + const makeAuthResult = ( + status: 'unauthorized' | 'partially_authorized' | 'fully_authorized', + typeMap: Record = {} + ) => ({ + status, + typeMap: new Map(Object.entries(typeMap)), + }); + + it('throws if authorizationResult.status is "unauthorized"', () => { + const authorizationResult = makeAuthResult('unauthorized'); + expect(() => + service.enforceAccessControl({ + authorizationResult, + typesRequiringAccessControl: new Set(['dashboard']), + currentSpace: 'default', + }) + ).toThrow(/Access denied/); + }); + + it('throws if not globally authorized and not authorized in current space', () => { + const authorizationResult = makeAuthResult('partially_authorized', { + dashboard: { + manage_access_control: { + isGloballyAuthorized: false, + authorizedSpaces: ['foo'], + }, + }, + }); + expect(() => + service.enforceAccessControl({ + authorizationResult, + typesRequiringAccessControl: new Set(['dashboard']), + currentSpace: 'default', + }) + ).toThrow(/Access denied/); + }); + + it('does not throw if globally authorized', () => { + const authorizationResult = makeAuthResult('fully_authorized', { + dashboard: { + manage_access_control: { + isGloballyAuthorized: true, + authorizedSpaces: [], + }, + }, + }); + expect(() => + service.enforceAccessControl({ + authorizationResult, + typesRequiringAccessControl: new Set(['dashboard']), + currentSpace: 'default', + }) + ).not.toThrow(); + }); + + it('does not throw if authorized in current space', () => { + const authorizationResult = makeAuthResult('fully_authorized', { + dashboard: { + manage_access_control: { + isGloballyAuthorized: false, + authorizedSpaces: ['default'], + }, + }, + }); + expect(() => + service.enforceAccessControl({ + authorizationResult, + typesRequiringAccessControl: new Set(['dashboard']), + currentSpace: 'default', + }) + ).not.toThrow(); + }); + + it('does not throw if typesRequiringAccessControl is empty', () => { + const authorizationResult = makeAuthResult('fully_authorized', {}); + expect(() => + service.enforceAccessControl({ + authorizationResult, + typesRequiringAccessControl: new Set(), + currentSpace: 'default', + }) + ).not.toThrow(); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/security/server/saved_objects/access_control_service.ts b/x-pack/platform/plugins/shared/security/server/saved_objects/access_control_service.ts new file mode 100644 index 0000000000000..648e126351cd9 --- /dev/null +++ b/x-pack/platform/plugins/shared/security/server/saved_objects/access_control_service.ts @@ -0,0 +1,159 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ISavedObjectTypeRegistry } from '@kbn/core/server'; +import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; +import type { + AuthorizeObject, + CheckAuthorizationResult, + GetObjectsRequiringPrivilegeCheckResult, + ObjectRequiringPrivilegeCheckResult, +} from '@kbn/core-saved-objects-server/src/extensions/security'; +import type { AuthenticatedUser } from '@kbn/security-plugin-types-common'; + +import { SecurityAction } from '.'; + +export const MANAGE_ACCESS_CONTROL_ACTION = 'manage_access_control'; + +interface AccessControlServiceParams { + typeRegistry?: ISavedObjectTypeRegistry; +} + +export class AccessControlService { + private userForOperation: AuthenticatedUser | null = null; + private typeRegistry: ISavedObjectTypeRegistry | undefined; + + constructor({ typeRegistry }: AccessControlServiceParams) { + this.typeRegistry = typeRegistry; + } + + setUserForOperation(user: AuthenticatedUser | null) { + this.userForOperation = user; + } + + shouldObjectRequireAccessControl(params: { + object: AuthorizeObject; + currentUser: AuthenticatedUser | null; + actions: Set; + }) { + const { object, currentUser, actions } = params; + + if (!this.typeRegistry?.supportsAccessControl(object.type)) { + return false; + } + + const { accessControl } = object; + if (!accessControl) { + return false; + } + + const actionsIgnoringDefaultMode = new Set([ + SecurityAction.CREATE, + SecurityAction.BULK_CREATE, + SecurityAction.UPDATE, + SecurityAction.BULK_UPDATE, + SecurityAction.DELETE, + SecurityAction.BULK_DELETE, + ]); + + const anyActionsForcingDefaultCheck = Array.from(actions).some( + (item) => !actionsIgnoringDefaultMode.has(item) + ); + + if (!anyActionsForcingDefaultCheck && accessControl.accessMode === 'default') { + return false; + } + + return !currentUser || accessControl.owner !== currentUser.profile_uid; + } + + getObjectsRequiringPrivilegeCheck({ + objects, + actions, + }: { + objects: AuthorizeObject[]; + actions: Set; + }): GetObjectsRequiringPrivilegeCheckResult { + if (!this.typeRegistry) { + return { types: new Set(), objects: [] }; + } + const currentUser = this.userForOperation; + const typesRequiringAccessControl = new Set(); + + const results: ObjectRequiringPrivilegeCheckResult[] = objects.map((object) => { + const requiresManageAccessControl = this.shouldObjectRequireAccessControl({ + object, + currentUser, + actions, + }); + + if (requiresManageAccessControl) { + typesRequiringAccessControl.add(object.type); + } + + return { + type: object.type, + id: object.id, + ...(object.name && { name: object.name }), + requiresManageAccessControl, + }; + }); + + return { types: typesRequiringAccessControl, objects: results }; + } + + enforceAccessControl({ + authorizationResult, + typesRequiringAccessControl, + currentSpace, + addAuditEventFn, + }: { + authorizationResult: CheckAuthorizationResult; + typesRequiringAccessControl: Set; + currentSpace: string; + addAuditEventFn?: (types: string[]) => void; + }) { + if (authorizationResult.status === 'unauthorized') { + const typeList = [...typesRequiringAccessControl].sort(); + addAuditEventFn?.(typeList); + throw SavedObjectsErrorHelpers.decorateForbiddenError( + new Error(`Access denied: Unable to manage access control for ${typeList}`) + ); + } + + const { typeMap } = authorizationResult; + const unauthorizedTypes: Set = new Set(); + + for (const type of typesRequiringAccessControl) { + if (!this.typeRegistry?.supportsAccessControl(type)) { + continue; + } + const typeAuth = typeMap.get(type); + const accessControlAuth = typeAuth?.[MANAGE_ACCESS_CONTROL_ACTION as A]; + if (!accessControlAuth) { + unauthorizedTypes.add(type); + } else { + // Check if user has global authorization or authorization in at least one space + if ( + !accessControlAuth.isGloballyAuthorized && + (!accessControlAuth.authorizedSpaces || + !accessControlAuth.authorizedSpaces.includes(currentSpace)) + ) { + unauthorizedTypes.add(type); + } + } + } + // If we found unauthorized types, throw an error + if (unauthorizedTypes.size > 0) { + const typeList = [...unauthorizedTypes].sort(); + addAuditEventFn?.(typeList); + throw SavedObjectsErrorHelpers.decorateForbiddenError( + new Error(`Access denied: Unable to manage access control for ${typeList}`) + ); + } + } +} diff --git a/x-pack/platform/plugins/shared/security/server/saved_objects/access_control_transforms.test.ts b/x-pack/platform/plugins/shared/security/server/saved_objects/access_control_transforms.test.ts new file mode 100644 index 0000000000000..6443d052335b5 --- /dev/null +++ b/x-pack/platform/plugins/shared/security/server/saved_objects/access_control_transforms.test.ts @@ -0,0 +1,89 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Transform } from 'stream'; + +import type { ISavedObjectTypeRegistry } from '@kbn/core/server'; + +import { getImportTransformsFactory } from './access_control_transforms'; + +describe('Access Control Transforms', () => { + // Mock type registry (expand to satisfy ISavedObjectTypeRegistry) + const typeRegistry = { + supportsAccessControl: (type: string) => type === 'dashboard', + } as unknown as jest.Mocked; + + // Full AuthenticatedUser mock + // const makeUser = (profileUid: string | null): AuthenticatedUser | null => + // profileUid + // ? { + // username: profileUid, + // profile_uid: profileUid, + // authentication_realm: { name: '', type: '' }, + // lookup_realm: { name: '', type: '' }, + // authentication_provider: { name: 'basic', type: 'basic' }, + // authentication_type: 'basic', + // roles: [], + // enabled: true, + // elastic_cloud_user: false, + // } + // : null; + + // describe('exportTransform', () => { + // it('strips the owner field for all objects that contain access control metadata', () => { + // const objects = [ + // { + // type: 'a', + // id: 'id_1', + // attributes: {}, + // references: [], + // accessControl: { accessMode: 'read_only' as const, owner: 'alice' }, + // }, + // { + // type: 'b', + // id: 'id_2', + // attributes: {}, + // references: [], + // accessControl: { accessMode: 'read_only' as const, owner: 'alice' }, + // }, + // { + // type: 'c', + // id: 'id_3', + // attributes: {}, + // references: [], + // }, + // { + // type: 'd', + // id: 'id_4', + // attributes: {}, + // references: [], + // accessControl: { accessMode: 'read_only' as const, owner: 'bob' }, + // }, + // ]; + + // const result = exportTransform({ request }, objects); + // expect(result).toEqual( + // objects.map((obj) => ({ + // ...obj, + // ...(obj.accessControl && { accessControl: { ...obj.accessControl, owner: '' } }), + // })) + // ); + // }); + // }); + + describe('getImportTransformsFactory', () => { + it(`returns a function that creates the import transforms`, () => { + const createImportTransforms = getImportTransformsFactory(); + expect(createImportTransforms).toBeInstanceOf(Function); + const importTransforms = createImportTransforms(typeRegistry, []); + expect(importTransforms).toHaveProperty('mapStream'); + expect(importTransforms).toHaveProperty('filterStream'); + expect(importTransforms.mapStream).toBeInstanceOf(Transform); + expect(importTransforms.filterStream).toBeInstanceOf(Transform); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/security/server/saved_objects/access_control_transforms.ts b/x-pack/platform/plugins/shared/security/server/saved_objects/access_control_transforms.ts new file mode 100644 index 0000000000000..b3b536b314f7e --- /dev/null +++ b/x-pack/platform/plugins/shared/security/server/saved_objects/access_control_transforms.ts @@ -0,0 +1,90 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + ISavedObjectTypeRegistry, + SavedObject, + SavedObjectsImportFailure, +} from '@kbn/core/server'; +import type { + AccessControlImportTransforms, + AccessControlImportTransformsFactory, +} from '@kbn/core-saved-objects-server'; +import { createFilterStream, createMapStream } from '@kbn/utils'; + +export function getImportTransformsFactory(): AccessControlImportTransformsFactory { + return ( + typeRegistry: ISavedObjectTypeRegistry, + errors: SavedObjectsImportFailure[] + ): AccessControlImportTransforms => { + const filterStream = createFilterStream>((obj) => { + const typeSupportsAccessControl = typeRegistry.supportsAccessControl(obj.type); + const { title } = obj.attributes; + + // In phase 1, these checks are irrelevent. We strip the incoming metadata to apply the default behavior of bulk_create. + // In phase 2, we will need to make these checks in order to validate when an Admin chooses to apply access control on import. + // GH Issue: https://github.com/elastic/kibana/issues/242671 + + // if (typeSupportsAccessControl && obj.accessControl && !obj.accessControl.accessMode) { + // errors.push({ + // id: obj.id, + // type: obj.type, + // meta: { title }, + // error: { + // type: 'missing_access_control_mode_metadata', + // }, + // }); + // return false; + // } + + // if ( + // typeSupportsAccessControl && + // obj.accessControl && + // (!obj.accessControl.owner || obj.accessControl.owner.trim().length === 0) + // ) { + // errors.push({ + // id: obj.id, + // type: obj.type, + // meta: { title }, + // error: { + // type: 'missing_access_control_owner_metadata', + // }, + // }); + // return false; + // } + + if (!typeSupportsAccessControl && obj.accessControl) { + errors.push({ + id: obj.id, + type: obj.type, + meta: { title }, + error: { + type: 'unexpected_access_control_metadata', + }, + }); + return false; + } + + return true; + }); + + // This is needed to strip incoming access control metadata, phase 1 for all users + // phase 2 for non-Admin users, and for admin users who are not applying access control on import + const mapStream = createMapStream((obj: SavedObject) => { + const typeSupportsAccessControl = typeRegistry.supportsAccessControl(obj.type); + + if (typeSupportsAccessControl && obj.accessControl) { + delete obj.accessControl; + } + return { + ...obj, + }; + }); + + return { filterStream, mapStream }; + }; +} diff --git a/x-pack/platform/plugins/shared/security/server/saved_objects/index.ts b/x-pack/platform/plugins/shared/security/server/saved_objects/index.ts index d3ba37b8a439b..f17b7236097bd 100644 --- a/x-pack/platform/plugins/shared/security/server/saved_objects/index.ts +++ b/x-pack/platform/plugins/shared/security/server/saved_objects/index.ts @@ -9,9 +9,12 @@ import type { AuthenticatedUser, CoreSetup, KibanaRequest } from '@kbn/core/serv import { SavedObjectsClient } from '@kbn/core/server'; import type { AuditServiceSetup } from '@kbn/security-plugin-types-server'; +import { getImportTransformsFactory } from './access_control_transforms'; import { SavedObjectsSecurityExtension } from './saved_objects_security_extension'; import type { AuthorizationServiceSetupInternal } from '../authorization'; +export { SecurityAction } from './types'; + interface SetupSavedObjectsParams { audit: AuditServiceSetup; authz: Pick< @@ -42,7 +45,7 @@ export function setupSavedObjects({ } ); - savedObjects.setSecurityExtension(({ request }) => { + savedObjects.setSecurityExtension(({ request, typeRegistry }) => { return authz.mode.useRbacForRequest(request) ? new SavedObjectsSecurityExtension({ actions: authz.actions, @@ -50,9 +53,14 @@ export function setupSavedObjects({ checkPrivileges: authz.checkSavedObjectsPrivilegesWithRequest(request), errors: SavedObjectsClient.errors, getCurrentUser: () => getCurrentUser(request), + typeRegistry, }) : undefined; }); + + savedObjects.setAccessControlTransforms({ + createImportTransforms: getImportTransformsFactory(), + }); } export { SavedObjectsSecurityExtension }; diff --git a/x-pack/platform/plugins/shared/security/server/saved_objects/saved_objects_security_extension.test.ts b/x-pack/platform/plugins/shared/security/server/saved_objects/saved_objects_security_extension.test.ts index 8af8ed2c84d35..a8661da537438 100644 --- a/x-pack/platform/plugins/shared/security/server/saved_objects/saved_objects_security_extension.test.ts +++ b/x-pack/platform/plugins/shared/security/server/saved_objects/saved_objects_security_extension.test.ts @@ -11,6 +11,7 @@ import type { SavedObjectsFindResult, SavedObjectsResolveResponse, } from '@kbn/core/server'; +import { typeRegistryMock } from '@kbn/core-saved-objects-base-server-mocks'; import type { LegacyUrlAliasTarget } from '@kbn/core-saved-objects-common'; import type { AuthorizeBulkGetObject, @@ -24,11 +25,9 @@ import type { CheckSavedObjectsPrivileges, } from '@kbn/security-plugin-types-server'; -import { - AuditAction, - SavedObjectsSecurityExtension, - SecurityAction, -} from './saved_objects_security_extension'; +import { MANAGE_ACCESS_CONTROL_ACTION } from './access_control_service'; +import { AuditAction, SavedObjectsSecurityExtension } from './saved_objects_security_extension'; +import { SecurityAction } from './types'; import { auditLoggerMock } from '../audit/mocks'; import { Actions } from '../authorization'; @@ -52,6 +51,18 @@ const addAuditEventSpy = jest.spyOn( ); const getCurrentUser = jest.fn(); +const accessControlServiceMock = { + setUserForOperation: jest.fn(), + getTypesRequiringPrivilegeCheck: jest + .fn() + .mockReturnValue({ typesRequiringAccessControl: new Set() }), + enforceAccessControl: jest.fn(), +}; + +Object.defineProperty(SavedObjectsSecurityExtension.prototype, 'accessControlService', { + get: () => accessControlServiceMock, +}); + const obj1 = { type: 'a', id: '6.0.0-alpha1', @@ -115,16 +126,22 @@ function setup({ includeSavedObjectNames = true }: { includeSavedObjectNames?: b decorateGeneralError: jest.fn().mockImplementation((err) => err), } as unknown as jest.Mocked; const checkPrivileges: jest.MockedFunction = jest.fn(); + + const typeRegistryMocked = typeRegistryMock.create(); + typeRegistryMocked.supportsAccessControl.mockImplementation((type) => type === 'dashboard'); const securityExtension = new SavedObjectsSecurityExtension({ actions, auditLogger, errors, checkPrivileges, getCurrentUser, + typeRegistry: typeRegistryMocked, }); return { actions, auditLogger, errors, checkPrivileges, securityExtension }; } +// ToDo: test inaccessible objects when authorizing objects with access control + describe('#authorize (unpublished by interface)', () => { beforeEach(() => { checkAuthorizationSpy.mockClear(); @@ -266,6 +283,7 @@ describe('#authorize (unpublished by interface)', () => { bulk_update: { authorizedSpaces: ['x', 'y'] }, ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, }), + inaccessibleObjects: new Set(), }); }); @@ -322,6 +340,7 @@ describe('#authorize (unpublished by interface)', () => { create: { authorizedSpaces: ['x'] }, ['login:']: { authorizedSpaces: ['x', 'y'] }, }), + inaccessibleObjects: new Set(), }); }); @@ -369,6 +388,7 @@ describe('#authorize (unpublished by interface)', () => { .set('a', { ['login:']: { authorizedSpaces: ['y'] } }) .set('b', { ['login:']: { authorizedSpaces: ['y'] } }) .set('c', { ['login:']: { authorizedSpaces: ['y'] } }), + inaccessibleObjects: new Set(), }); }); @@ -396,6 +416,7 @@ describe('#authorize (unpublished by interface)', () => { typeMap: new Map().set('b', { bulk_update: { authorizedSpaces: ['y'] }, // should NOT be authorized for conflicted privilege }), + inaccessibleObjects: new Set(), }); }); }); @@ -1141,11 +1162,13 @@ describe('#authorize (unpublished by interface)', () => { spaces, actions: new Set([SecurityAction.CLOSE_POINT_IN_TIME]), // this is currently the only security action that does not require authz }) - ).rejects.toThrowError('No actions specified for authorization check'); + ).rejects.toThrowError( + 'No actions or access control types specified for authorization check' + ); }); }); - describe('scecurity actions with no audit action', () => { + describe('security actions with no audit action', () => { // These arguments are used for all unit tests below const types = new Set(['a', 'b', 'c']); const spaces = new Set(['x', 'y']); @@ -1433,6 +1456,7 @@ describe('#create', () => { create: { isGloballyAuthorized: true, authorizedSpaces: [] }, ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, }), + inaccessibleObjects: new Set(), }); expect(enforceAuthorizationSpy).toHaveBeenCalledTimes(1); }); @@ -1703,6 +1727,7 @@ describe('#create', () => { expect(result).toEqual({ status: 'fully_authorized', typeMap: expectedTypeMap, + inaccessibleObjects: new Set(), }); expect(enforceAuthorizationSpy).toHaveBeenCalledTimes(1); }); @@ -1748,6 +1773,7 @@ describe('#create', () => { bulk_create: { authorizedSpaces: ['x', 'y'] }, ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, }), + inaccessibleObjects: new Set(), }); expect(enforceAuthorizationSpy).toHaveBeenCalledTimes(1); }); @@ -1954,6 +1980,7 @@ describe('update', () => { update: { isGloballyAuthorized: true, authorizedSpaces: [] }, ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, }), + inaccessibleObjects: new Set(), }); expect(enforceAuthorizationSpy).toHaveBeenCalledTimes(1); }); @@ -2222,6 +2249,7 @@ describe('update', () => { expect(result).toEqual({ status: 'fully_authorized', typeMap: expectedTypeMap, + inaccessibleObjects: new Set(), }); expect(enforceAuthorizationSpy).toHaveBeenCalledTimes(1); }); @@ -2267,6 +2295,7 @@ describe('update', () => { bulk_update: { authorizedSpaces: ['x', 'y'] }, ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, }), + inaccessibleObjects: new Set(), }); expect(enforceAuthorizationSpy).toHaveBeenCalledTimes(1); }); @@ -2468,6 +2497,7 @@ describe('delete', () => { delete: { isGloballyAuthorized: true, authorizedSpaces: [] }, ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, }), + inaccessibleObjects: new Set(), }); expect(enforceAuthorizationSpy).toHaveBeenCalledTimes(1); }); @@ -2700,6 +2730,7 @@ describe('delete', () => { expect(result).toEqual({ status: 'fully_authorized', typeMap: expectedTypeMap, + inaccessibleObjects: new Set(), }); expect(enforceAuthorizationSpy).toHaveBeenCalledTimes(1); }); @@ -2744,6 +2775,7 @@ describe('delete', () => { bulk_delete: { authorizedSpaces: ['x'] }, ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, }), + inaccessibleObjects: new Set(), }); expect(enforceAuthorizationSpy).toHaveBeenCalledTimes(1); }); @@ -2955,6 +2987,7 @@ describe('get', () => { get: { isGloballyAuthorized: true, authorizedSpaces: [] }, ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, }), + inaccessibleObjects: new Set(), }); expect(enforceAuthorizationSpy).toHaveBeenCalledTimes(1); }); @@ -2973,6 +3006,7 @@ describe('get', () => { get: { isGloballyAuthorized: true, authorizedSpaces: [] }, ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, }), + inaccessibleObjects: new Set(), }); expect(enforceAuthorizationSpy).toHaveBeenCalledTimes(1); }); @@ -3279,6 +3313,7 @@ describe('get', () => { expect(result).toEqual({ status: 'fully_authorized', typeMap: expectedTypeMap, + inaccessibleObjects: new Set(), }); expect(enforceAuthorizationSpy).toHaveBeenCalledTimes(1); }); @@ -3313,6 +3348,7 @@ describe('get', () => { bulk_get: { authorizedSpaces: ['x', 'z'] }, ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, }), + inaccessibleObjects: new Set(), }); expect(enforceAuthorizationSpy).toHaveBeenCalledTimes(1); }); @@ -3596,6 +3632,7 @@ describe(`#authorizeCheckConflicts`, () => { expect(result).toEqual({ status: 'fully_authorized', typeMap: expectedTypeMap, + inaccessibleObjects: new Set(), }); expect(enforceAuthorizationSpy).toHaveBeenCalledTimes(1); }); @@ -3640,6 +3677,7 @@ describe(`#authorizeCheckConflicts`, () => { bulk_create: { authorizedSpaces: ['x'] }, ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, }), + inaccessibleObjects: new Set(), }); expect(enforceAuthorizationSpy).toHaveBeenCalledTimes(1); }); @@ -3802,6 +3840,7 @@ describe(`#authorizeRemoveReferences`, () => { expect(result).toEqual({ status: 'fully_authorized', typeMap: expectedTypeMap, + inaccessibleObjects: new Set(), }); expect(enforceAuthorizationSpy).toHaveBeenCalledTimes(1); }); @@ -3827,6 +3866,7 @@ describe(`#authorizeRemoveReferences`, () => { typeMap: new Map().set(obj1.type, { delete: { isGloballyAuthorized: true, authorizedSpaces: [] }, }), + inaccessibleObjects: new Set(), }); expect(enforceAuthorizationSpy).toHaveBeenCalledTimes(1); }); @@ -4038,6 +4078,7 @@ describe(`#authorizeOpenPointInTime`, () => { expect(result).toEqual({ status: 'fully_authorized', typeMap: expectedTypeMap, + inaccessibleObjects: new Set(), }); expect(enforceAuthorizationSpy).not.toHaveBeenCalled(); }); @@ -4063,6 +4104,7 @@ describe(`#authorizeOpenPointInTime`, () => { typeMap: new Map().set(obj1.type, { open_point_in_time: { isGloballyAuthorized: true, authorizedSpaces: [] }, }), + inaccessibleObjects: new Set(), }); expect(enforceAuthorizationSpy).not.toHaveBeenCalled(); }); @@ -5455,6 +5497,7 @@ describe('#authorizeUpdateSpaces', () => { expect(result).toEqual({ status: 'fully_authorized', typeMap: expectedTypeMap, + inaccessibleObjects: new Set(), }); expect(enforceAuthorizationSpy).toHaveBeenCalledTimes(1); }); @@ -5518,6 +5561,7 @@ describe('#authorizeUpdateSpaces', () => { share_to_space: { authorizedSpaces: ['x', ...spacesToAdd, ...spacesToRemove] }, ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, }), + inaccessibleObjects: new Set(), }); expect(enforceAuthorizationSpy).toHaveBeenCalledTimes(1); }); @@ -5739,6 +5783,7 @@ describe('find', () => { expect(result).toEqual({ status: 'fully_authorized', typeMap: expectedTypeMap, + inaccessibleObjects: new Set(), }); expect(enforceAuthorizationSpy).not.toHaveBeenCalled(); }); @@ -5764,6 +5809,7 @@ describe('find', () => { typeMap: new Map().set(obj1.type, { find: { isGloballyAuthorized: true, authorizedSpaces: [] }, }), + inaccessibleObjects: new Set(), }); expect(enforceAuthorizationSpy).not.toHaveBeenCalled(); }); @@ -5795,6 +5841,7 @@ describe('find', () => { typeMap: new Map().set(obj1.type, { 'login:': { isGloballyAuthorized: true, authorizedSpaces: [] }, }), + inaccessibleObjects: new Set(), }); expect(enforceAuthorizationSpy).not.toHaveBeenCalled(); }); @@ -6374,3 +6421,511 @@ describe(`#auditObjectsForSpaceDeletion`, () => { }); }); }); + +describe('#authorizeChangeAccessControl', () => { + const namespace = 'x'; + const objectsWithExistingNamespaces = [ + { + type: 'dashboard', + id: '1', + existingNamespaces: [], + accessControl: { owner: 'fake_owner_id', accessMode: 'write_restricted' as const }, + }, + { + type: 'visualization', + id: '2', + existingNamespaces: [], + accessControl: { owner: 'fake_owner_id', accessMode: 'write_restricted' as const }, + }, + ]; + + beforeEach(() => { + // Reset spies and mocks + accessControlServiceMock.setUserForOperation.mockReset(); + accessControlServiceMock.getTypesRequiringPrivilegeCheck.mockReset(); + accessControlServiceMock.enforceAccessControl.mockReset(); + checkAuthorizationSpy.mockReset(); + + // Default: no types require access control + accessControlServiceMock.getTypesRequiringPrivilegeCheck.mockReturnValue({ + typesRequiringAccessControl: new Set(), + }); + + // Default: current user is not owner/admin + getCurrentUser.mockReturnValue({ + profile_uid: 'different_profile_id', + username: 'test_user', + }); + }); + afterEach(() => { + checkAuthorizationSpy.mockClear(); + enforceAuthorizationSpy.mockClear(); + redactNamespacesSpy.mockClear(); + authorizeSpy.mockClear(); + auditHelperSpy.mockClear(); + addAuditEventSpy.mockClear(); + }); + + test('throws an error when `namespace` is empty', async () => { + const { securityExtension, checkPrivileges } = setup(); + await expect( + securityExtension.authorizeChangeAccessControl( + { + namespace: '', + objects: objectsWithExistingNamespaces, + }, + 'changeOwnership' + ) + ).rejects.toThrowError('namespace cannot be an empty string'); + expect(checkPrivileges).not.toHaveBeenCalled(); + }); + + test('throws an error when objects array is empty', async () => { + const { securityExtension, checkPrivileges } = setup(); + await expect( + securityExtension.authorizeChangeAccessControl( + { + namespace, + objects: [], + }, + 'changeOwnership' + ) + ).rejects.toThrowError('No objects specified for manage_access_control authorization'); + expect(checkPrivileges).not.toHaveBeenCalled(); + }); + + test('calls checkAuthorization with expected options when types require access control', async () => { + const { securityExtension, checkPrivileges } = setup(); + accessControlServiceMock.getTypesRequiringPrivilegeCheck.mockReturnValueOnce({ + typesRequiringAccessControl: new Set(['dashboard']), + }); + setupSimpleCheckPrivsMockResolve( + checkPrivileges, + 'dashboard', + MANAGE_ACCESS_CONTROL_ACTION, + false + ); + checkAuthorizationSpy.mockResolvedValue({ + status: 'fully_authorized', + typeMap: new Map().set('dashboard', { + manage_access_control: { isGloballyAuthorized: true, authorizedSpaces: [] }, + ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, + }), + }); + + await securityExtension.authorizeChangeAccessControl( + { + namespace, + objects: objectsWithExistingNamespaces, + }, + 'changeOwnership' + ); + + expect(checkAuthorizationSpy).toHaveBeenCalledWith({ + types: new Set(['dashboard']), + spaces: new Set([namespace]), + actions: new Set([]), + options: { + allowGlobalResource: true, + typesRequiringAccessControl: new Set(['dashboard']), + }, + }); + }); + + test('throws forbidden error when access is unauthorized', async () => { + const { securityExtension, checkPrivileges } = setup(); + accessControlServiceMock.getTypesRequiringPrivilegeCheck.mockReturnValueOnce({ + typesRequiringAccessControl: new Set(['dashboard']), + }); + setupSimpleCheckPrivsMockResolve( + checkPrivileges, + 'dashboard', + MANAGE_ACCESS_CONTROL_ACTION, + false + ); + checkAuthorizationSpy.mockResolvedValue({ + status: 'unauthorized', + typeMap: new Map(), + }); + + await expect( + securityExtension.authorizeChangeAccessControl( + { + namespace, + objects: objectsWithExistingNamespaces, + }, + 'changeOwnership' + ) + ).rejects.toThrow(); + + expect(addAuditEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + action: AuditAction.UPDATE_OBJECTS_OWNER, + error: expect.any(Error), + unauthorizedTypes: ['dashboard'], + unauthorizedSpaces: [namespace], + }) + ); + }); + + test('allows operation when user is not admin but owner', async () => { + const currentUser = { + username: 'fake_owner', + profile_uid: 'fake_owner_id', + }; + const { securityExtension, checkPrivileges } = setup(); + getCurrentUser.mockReturnValue(currentUser); + setupSimpleCheckPrivsMockResolve( + checkPrivileges, + 'dashboard', + MANAGE_ACCESS_CONTROL_ACTION, + false + ); + const result = await securityExtension.authorizeChangeAccessControl( + { + namespace, + objects: objectsWithExistingNamespaces, + }, + 'changeOwnership' + ); + expect(result).toEqual({ + status: 'fully_authorized', + typeMap: new Map(), + }); + }); + + test('throws error if all objects are non access-control objects', async () => { + const { securityExtension } = setup(); + const objects = [ + { + type: 'non_access_control_type', + id: '1', + existingNamespaces: [], + accessControl: { owner: 'fake_owner_id', accessMode: 'write_restricted' as const }, + }, + { + type: 'visualization', + id: '2', + existingNamespaces: [], + accessControl: { owner: 'fake_owner_id', accessMode: 'write_restricted' as const }, + }, + ]; + + await expect( + securityExtension.authorizeChangeAccessControl( + { + namespace, + objects, + }, + 'changeOwnership' + ) + ).rejects.toMatchObject({ + output: { + payload: { + message: expect.stringContaining( + 'Unable to manage_access_control for types non_access_control_type, visualization' + ), + }, + }, + }); + }); + + test('audits success event when changeOwnership operation is authorized', async () => { + const { securityExtension, checkPrivileges } = setup(); + accessControlServiceMock.getTypesRequiringPrivilegeCheck.mockReturnValueOnce({ + typesRequiringAccessControl: new Set(['dashboard', 'visualization']), + }); + setupSimpleCheckPrivsMockResolve( + checkPrivileges, + 'dashboard', + MANAGE_ACCESS_CONTROL_ACTION, + false + ); + checkAuthorizationSpy.mockResolvedValue({ + status: 'fully_authorized', + typeMap: new Map() + .set('dashboard', { + manage_access_control: { isGloballyAuthorized: true, authorizedSpaces: [] }, + ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, + }) + .set('visualization', { + manage_access_control: { isGloballyAuthorized: true, authorizedSpaces: [] }, + ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, + }), + }); + + await securityExtension.authorizeChangeAccessControl( + { + namespace, + objects: objectsWithExistingNamespaces, + }, + 'changeOwnership' + ); + + expect(auditHelperSpy).toHaveBeenCalledTimes(1); + expect(auditHelperSpy).toHaveBeenCalledWith({ + action: AuditAction.UPDATE_OBJECTS_OWNER, + objects: objectsWithExistingNamespaces, + useSuccessOutcome: true, + addToSpaces: undefined, + deleteFromSpaces: undefined, + unauthorizedSpaces: undefined, + unauthorizedTypes: undefined, + error: undefined, + }); + }); + + test('audits success event when changeAccessMode operation is authorized', async () => { + const { securityExtension, checkPrivileges } = setup(); + accessControlServiceMock.getTypesRequiringPrivilegeCheck.mockReturnValueOnce({ + typesRequiringAccessControl: new Set(['dashboard', 'visualization']), + }); + setupSimpleCheckPrivsMockResolve( + checkPrivileges, + 'dashboard', + MANAGE_ACCESS_CONTROL_ACTION, + false + ); + checkAuthorizationSpy.mockResolvedValue({ + status: 'fully_authorized', + typeMap: new Map() + .set('dashboard', { + manage_access_control: { isGloballyAuthorized: true, authorizedSpaces: [] }, + ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, + }) + .set('visualization', { + manage_access_control: { isGloballyAuthorized: true, authorizedSpaces: [] }, + ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, + }), + }); + + await securityExtension.authorizeChangeAccessControl( + { + namespace, + objects: objectsWithExistingNamespaces, + }, + 'changeAccessMode' + ); + + expect(auditHelperSpy).toHaveBeenCalledTimes(1); + expect(auditHelperSpy).toHaveBeenCalledWith({ + action: AuditAction.UPDATE_OBJECTS_ACCESS_MODE, + objects: objectsWithExistingNamespaces, + useSuccessOutcome: true, + addToSpaces: undefined, + deleteFromSpaces: undefined, + unauthorizedSpaces: undefined, + unauthorizedTypes: undefined, + error: undefined, + }); + }); + test('audits failure event when changeAccessMode operation is unauthorized', async () => { + const { securityExtension, checkPrivileges } = setup(); + accessControlServiceMock.getTypesRequiringPrivilegeCheck.mockReturnValueOnce({ + typesRequiringAccessControl: new Set(['dashboard', 'visualization']), + }); + setupSimpleCheckPrivsMockResolve( + checkPrivileges, + 'dashboard', + MANAGE_ACCESS_CONTROL_ACTION, + false + ); + checkAuthorizationSpy.mockResolvedValue({ + status: 'unauthorized', + typeMap: new Map(), + }); + + await expect( + securityExtension.authorizeChangeAccessControl( + { + namespace, + objects: objectsWithExistingNamespaces, + }, + 'changeAccessMode' + ) + ).rejects.toThrow(); + + expect(addAuditEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + action: AuditAction.UPDATE_OBJECTS_ACCESS_MODE, + error: expect.any(Error), + unauthorizedTypes: expect.arrayContaining(['dashboard']), + unauthorizedSpaces: [namespace], + }) + ); + expect(auditHelperSpy).not.toHaveBeenCalled(); + }); + + test('audits failure event with multiple types when changeOwnership is unauthorized', async () => { + const { securityExtension, checkPrivileges } = setup(); + accessControlServiceMock.getTypesRequiringPrivilegeCheck.mockReturnValueOnce({ + typesRequiringAccessControl: new Set(['dashboard', 'visualization', 'map']), + }); + setupSimpleCheckPrivsMockResolve( + checkPrivileges, + 'dashboard', + MANAGE_ACCESS_CONTROL_ACTION, + false + ); + checkAuthorizationSpy.mockResolvedValue({ + status: 'unauthorized', + typeMap: new Map(), + }); + + const objects = [ + ...objectsWithExistingNamespaces, + { + type: 'map', + id: '3', + existingNamespaces: [], + accessControl: { owner: 'fake_owner_id', accessMode: 'write_restricted' as const }, + }, + ]; + + await expect( + securityExtension.authorizeChangeAccessControl( + { + namespace, + objects, + }, + 'changeOwnership' + ) + ).rejects.toThrow(); + + expect(addAuditEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + action: AuditAction.UPDATE_OBJECTS_OWNER, + error: expect.any(Error), + unauthorizedTypes: expect.arrayContaining(['dashboard']), + unauthorizedSpaces: [namespace], + }) + ); + expect(auditHelperSpy).not.toHaveBeenCalled(); + }); + + test('audits failure event when partially authorized but not in current space', async () => { + const { securityExtension, checkPrivileges } = setup(); + accessControlServiceMock.getTypesRequiringPrivilegeCheck.mockReturnValueOnce({ + typesRequiringAccessControl: new Set(['dashboard']), + }); + setupSimpleCheckPrivsMockResolve( + checkPrivileges, + 'dashboard', + MANAGE_ACCESS_CONTROL_ACTION, + false + ); + // User is authorized in 'y' space but not in current space 'x' + checkAuthorizationSpy.mockResolvedValue({ + status: 'partially_authorized', + typeMap: new Map().set('dashboard', { + manage_access_control: { + isGloballyAuthorized: false, + authorizedSpaces: ['y'], + }, + ['login:']: { isGloballyAuthorized: false, authorizedSpaces: ['y'] }, + }), + }); + + await expect( + securityExtension.authorizeChangeAccessControl( + { + namespace, + objects: objectsWithExistingNamespaces, + }, + 'changeOwnership' + ) + ).rejects.toThrow(); + + expect(addAuditEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + action: AuditAction.UPDATE_OBJECTS_OWNER, + error: expect.any(Error), + unauthorizedTypes: ['dashboard'], + unauthorizedSpaces: [namespace], + }) + ); + expect(auditHelperSpy).not.toHaveBeenCalled(); + }); + + test('audits failure event when partially authorized but not in current space for changeAccessMode', async () => { + const { securityExtension, checkPrivileges } = setup(); + accessControlServiceMock.getTypesRequiringPrivilegeCheck.mockReturnValueOnce({ + typesRequiringAccessControl: new Set(['dashboard', 'visualization']), + }); + setupSimpleCheckPrivsMockResolve( + checkPrivileges, + 'dashboard', + MANAGE_ACCESS_CONTROL_ACTION, + false + ); + + checkAuthorizationSpy.mockResolvedValue({ + status: 'partially_authorized', + typeMap: new Map() + .set('dashboard', { + manage_access_control: { + isGloballyAuthorized: false, + authorizedSpaces: ['y'], + }, + ['login:']: { isGloballyAuthorized: false, authorizedSpaces: ['y'] }, + }) + .set('visualization', { + manage_access_control: { + isGloballyAuthorized: false, + authorizedSpaces: ['y'], + }, + ['login:']: { isGloballyAuthorized: false, authorizedSpaces: ['y'] }, + }), + }); + + await expect( + securityExtension.authorizeChangeAccessControl( + { + namespace, + objects: objectsWithExistingNamespaces, + }, + 'changeAccessMode' + ) + ).rejects.toThrow(); + + expect(addAuditEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + action: AuditAction.UPDATE_OBJECTS_ACCESS_MODE, + error: expect.any(Error), + unauthorizedTypes: expect.arrayContaining(['dashboard']), + unauthorizedSpaces: [namespace], + }) + ); + expect(auditHelperSpy).not.toHaveBeenCalled(); + }); + + test('does not audit success when operation fails', async () => { + const { securityExtension, checkPrivileges } = setup(); + accessControlServiceMock.getTypesRequiringPrivilegeCheck.mockReturnValueOnce({ + typesRequiringAccessControl: new Set(['dashboard']), + }); + setupSimpleCheckPrivsMockResolve( + checkPrivileges, + 'dashboard', + MANAGE_ACCESS_CONTROL_ACTION, + false + ); + checkAuthorizationSpy.mockResolvedValue({ + status: 'unauthorized', + typeMap: new Map(), + }); + + await expect( + securityExtension.authorizeChangeAccessControl( + { + namespace, + objects: objectsWithExistingNamespaces, + }, + 'changeOwnership' + ) + ).rejects.toThrow(); + + expect(addAuditEventSpy).toHaveBeenCalledTimes(1); + expect(auditHelperSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/platform/plugins/shared/security/server/saved_objects/saved_objects_security_extension.ts b/x-pack/platform/plugins/shared/security/server/saved_objects/saved_objects_security_extension.ts index 81f9240b1ebaf..e4e90719704e2 100644 --- a/x-pack/platform/plugins/shared/security/server/saved_objects/saved_objects_security_extension.ts +++ b/x-pack/platform/plugins/shared/security/server/saved_objects/saved_objects_security_extension.ts @@ -6,17 +6,21 @@ */ import type { EcsEvent } from '@elastic/ecs'; - -import type { - SavedObjectReferenceWithContext, - SavedObjectsFindResult, - SavedObjectsResolveResponse, +import type { Payload } from '@hapi/boom'; + +import type { SavedObjectsClient } from '@kbn/core/server'; +import { + type Either, + isLeft, + left, + type SavedObjectAccessControl, + type SavedObjectReferenceWithContext, + type SavedObjectsFindResult, + type SavedObjectsResolveResponse, } from '@kbn/core-saved-objects-api-server'; -import type { SavedObjectsClient } from '@kbn/core-saved-objects-api-server-internal'; -import { isBulkResolveError } from '@kbn/core-saved-objects-api-server-internal/src/lib/apis/internals/internal_bulk_resolve'; import { LEGACY_URL_ALIAS_TYPE } from '@kbn/core-saved-objects-base-server-internal'; import type { LegacyUrlAliasTarget } from '@kbn/core-saved-objects-common'; -import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; +import { errorContent, SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; import type { AuthorizationTypeEntry, AuthorizationTypeMap, @@ -26,11 +30,13 @@ import type { AuthorizeBulkDeleteParams, AuthorizeBulkGetParams, AuthorizeBulkUpdateParams, + AuthorizeChangeAccessControlParams, AuthorizeCheckConflictsParams, AuthorizeCreateParams, AuthorizeDeleteParams, AuthorizeFindParams, AuthorizeGetParams, + AuthorizeObject, AuthorizeOpenPointInTimeParams, AuthorizeUpdateParams, AuthorizeUpdateSpacesParams, @@ -38,11 +44,16 @@ import type { CheckAuthorizationResult, GetFindRedactTypeMapParams, ISavedObjectsSecurityExtension, + ISavedObjectTypeRegistry, RedactNamespacesParams, SavedObject, WithAuditName, } from '@kbn/core-saved-objects-server'; -import type { AuthorizeObject } from '@kbn/core-saved-objects-server/src/extensions/security'; +import type { + AuthorizationResult, + GetObjectsRequiringPrivilegeCheckResult, + ObjectRequiringPrivilegeCheckResult, +} from '@kbn/core-saved-objects-server/src/extensions/security'; import { ALL_NAMESPACES_STRING, SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server'; import type { AuthenticatedUser } from '@kbn/security-plugin-types-common'; import type { @@ -52,7 +63,9 @@ import type { CheckSavedObjectsPrivileges, } from '@kbn/security-plugin-types-server'; +import { AccessControlService, MANAGE_ACCESS_CONTROL_ACTION } from './access_control_service'; import { isAuthorizedInAllSpaces } from './authorization_utils'; +import { SecurityAction } from './types'; import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../common/constants'; import { savedObjectEvent } from '../audit'; @@ -62,30 +75,7 @@ interface Params { errors: SavedObjectsClient['errors']; checkPrivileges: CheckSavedObjectsPrivileges; getCurrentUser: () => AuthenticatedUser | null; -} - -/** - * The SecurityAction enumeration contains values for all valid shared object - * security actions. The string for each value correlates to the ES operation. - */ -export enum SecurityAction { - CHECK_CONFLICTS, - CLOSE_POINT_IN_TIME, - COLLECT_MULTINAMESPACE_REFERENCES, - COLLECT_MULTINAMESPACE_REFERENCES_UPDATE_SPACES, - CREATE, - BULK_CREATE, - DELETE, - BULK_DELETE, - FIND, - GET, - BULK_GET, - INTERNAL_BULK_RESOLVE, - OPEN_POINT_IN_TIME, - REMOVE_REFERENCES, - UPDATE, - BULK_UPDATE, - UPDATE_OBJECTS_SPACES, + typeRegistry?: ISavedObjectTypeRegistry; } /** @@ -104,6 +94,8 @@ export enum AuditAction { CLOSE_POINT_IN_TIME = 'saved_object_close_point_in_time', COLLECT_MULTINAMESPACE_REFERENCES = 'saved_object_collect_multinamespace_references', // this is separate from 'saved_object_get' because the user is only accessing an object's metadata UPDATE_OBJECTS_SPACES = 'saved_object_update_objects_spaces', // this is separate from 'saved_object_update' because the user is only updating an object's metadata + UPDATE_OBJECTS_OWNER = 'saved_object_update_objects_owner', + UPDATE_OBJECTS_ACCESS_MODE = 'saved_object_update_objects_access_mode', } /** @@ -113,6 +105,7 @@ export interface SavedObjectAudit { type: string; id: string; name?: string; + accessControl?: SavedObjectAccessControl; } /** @@ -218,6 +211,13 @@ interface EnforceAuthorizationParams { typeMap: AuthorizationTypeMap; /** auditOptions - options for audit logging */ auditOptions?: AuditOptions; + /** The objects being operated on, used for object-level ownership checks */ + objects?: SavedObjectAudit[]; + + hasAllPrivileges?: boolean; + enforceAccessControl?: { + objectsRequiringPrivilegeCheck?: GetObjectsRequiringPrivilegeCheckResult; + }; } /** @@ -297,6 +297,7 @@ interface CheckAuthorizationParams { * allowGlobalResource - whether or not to allow global resources, false if options are undefined */ allowGlobalResource: boolean; + typesRequiringAccessControl?: Set; }; } @@ -312,14 +313,25 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten SecurityAction, { authzAction?: string; auditAction?: AuditAction } >; - - constructor({ actions, auditLogger, errors, checkPrivileges, getCurrentUser }: Params) { + private readonly typeRegistry: ISavedObjectTypeRegistry | undefined; + public readonly accessControlService: AccessControlService; + + constructor({ + actions, + auditLogger, + errors, + checkPrivileges, + getCurrentUser, + typeRegistry, + }: Params) { this.actions = actions; this.auditLogger = auditLogger; this.errors = errors; this.checkPrivilegesFunc = checkPrivileges; this.getCurrentUserFunc = getCurrentUser; + this.typeRegistry = typeRegistry; + this.accessControlService = new AccessControlService({ typeRegistry }); // This comment block is a quick reference for the action map, which maps authorization actions // and audit actions to a "security action" as used by the authorization methods. // Security Action ES AUTH ACTION AUDIT ACTION @@ -341,6 +353,8 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten // Update 'update' AuditAction.UPDATE // Bulk Update 'bulk_update' AuditAction.UPDATE // Update Objects Spaces 'share_to_space' AuditAction.UPDATE_OBJECTS_SPACES + // Change ownership 'manage_access_control' AuditAction.UPDATE_OBJECTS_OWNER + // Change access mode 'manage_access_control' AuditAction.UPDATE_OBJECTS_ACCESS_MODE this.actionMap = new Map([ [SecurityAction.CHECK_CONFLICTS, { authzAction: 'bulk_create', auditAction: undefined }], [ @@ -383,6 +397,20 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten SecurityAction.UPDATE_OBJECTS_SPACES, { authzAction: 'share_to_space', auditAction: AuditAction.UPDATE_OBJECTS_SPACES }, ], + [ + SecurityAction.CHANGE_OWNERSHIP, + { + authzAction: MANAGE_ACCESS_CONTROL_ACTION, + auditAction: AuditAction.UPDATE_OBJECTS_OWNER, + }, + ], + [ + SecurityAction.CHANGE_ACCESS_MODE, + { + authzAction: MANAGE_ACCESS_CONTROL_ACTION, + auditAction: AuditAction.UPDATE_OBJECTS_ACCESS_MODE, + }, + ], ]); } @@ -402,9 +430,15 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten const authzActions = new Set(); const auditActions = new Set(); for (const secAction of securityActions) { - const { authzAction, auditAction } = this.decodeSecurityAction(secAction); - if (authzAction) authzActions.add(authzAction as A); - if (auditAction) auditActions.add(auditAction as AuditAction); + // CHANGE_OWNERSHIP and CHANGE_ACCESS_MODE are handled separately from normal RBAC checks + if ( + secAction !== SecurityAction.CHANGE_OWNERSHIP && + secAction !== SecurityAction.CHANGE_ACCESS_MODE + ) { + const { authzAction, auditAction } = this.decodeSecurityAction(secAction); + if (authzAction) authzActions.add(authzAction as A); + if (auditAction) auditActions.add(auditAction as AuditAction); + } } return { authzActions, auditActions }; } @@ -414,30 +448,49 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten auditAction: AuditAction | undefined; } { const { authzAction, auditAction } = this.actionMap.get(securityAction)!; - return { authzAction, auditAction }; + return { + authzAction, + auditAction, + }; } private async checkAuthorization( params: CheckAuthorizationParams ): Promise> { const { types, spaces, actions, options = { allowGlobalResource: false } } = params; - const { allowGlobalResource } = options; + + const { allowGlobalResource, typesRequiringAccessControl } = options; if (types.size === 0) { throw new Error('No types specified for authorization check'); } if (spaces.size === 0) { throw new Error('No spaces specified for authorization check'); } - if (actions.size === 0) { - throw new Error('No actions specified for authorization check'); + + if ( + actions.size === 0 && + (!typesRequiringAccessControl || typesRequiringAccessControl.size === 0) + ) { + throw new Error('No actions or access control types specified for authorization check'); } const typesArray = [...types]; const actionsArray = [...actions]; + const privilegeActionsMap = new Map( typesArray.flatMap((type) => actionsArray.map((action) => [this.actions.savedObject.get(type, action), { type, action }]) ) ); + + if (typesRequiringAccessControl && typesRequiringAccessControl.size > 0) { + for (const type of typesRequiringAccessControl) { + privilegeActionsMap.set(this.actions.savedObject.get(type, MANAGE_ACCESS_CONTROL_ACTION), { + type, + action: MANAGE_ACCESS_CONTROL_ACTION as A, + }); + } + } + const privilegeActions = [...privilegeActionsMap.keys(), this.actions.login]; // Always check login action, we will need it later for redacting namespaces const { hasAllRequested, privileges } = await this.checkPrivileges( privilegeActions, @@ -445,6 +498,7 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten ); const missingPrivileges = getMissingPrivileges(privileges); + const typeMap = privileges.kibana.reduce>( (acc, { resource, privilege }) => { const missingPrivilegesAtResource = @@ -506,6 +560,7 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten } } } + return { typeMap, status: 'unauthorized' }; } @@ -540,13 +595,49 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten } } - /** + private allAccessControlObjectsAreInaccessible( + allAccessControlObjects: ObjectRequiringPrivilegeCheckResult[], + inaccessibleObjects: Set + ): boolean { + return ( + inaccessibleObjects.size > 0 && + inaccessibleObjects.size === allAccessControlObjects.length && + allAccessControlObjects.every((obj) => inaccessibleObjects.has(obj)) + ); + } + + private allRequestedObjectAreInaccessible( + typesAndSpaces: Map>, + inaccessibleTypes: Set, + allAccessControlObjects: ObjectRequiringPrivilegeCheckResult[], + inaccessibleObjects: Set + ): boolean { + const allTypes = [...typesAndSpaces.keys()]; + + const allAccessControlObjectsAreInaccessible = this.allAccessControlObjectsAreInaccessible( + allAccessControlObjects, + inaccessibleObjects + ); + + return ( + allAccessControlObjectsAreInaccessible && + allTypes.length === inaccessibleTypes.size && + allTypes.every((type) => inaccessibleTypes.has(type)) + ); + } + + /* * The enforce method uses the result of an authorization check authorization map) and a map * of types to spaces (type map) to determine if the action is authorized for all types and spaces * within the type map. If unauthorized for any type this method will throw. + * Enforce also optionally enforces access control restrictions, throwing if the user does not + * have the manage access control privilege for any objects that require it (owned by another user). + * Enforce will return a set of objects that were inaccessible due to access control restrictions, */ - private enforceAuthorization(params: EnforceAuthorizationParams): void { - const { typesAndSpaces, action, typeMap, auditOptions } = params; + private enforceAuthorization( + params: EnforceAuthorizationParams + ): Set { + const { typesAndSpaces, action, typeMap, auditOptions, enforceAccessControl } = params; const { objects: auditObjects, bypass = 'never', // default for bypass @@ -557,20 +648,60 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten const { authzAction, auditAction } = this.decodeSecurityAction(action); + const allAccessControlObjects = + enforceAccessControl?.objectsRequiringPrivilegeCheck?.objects ?? []; + + const accessControlObjectsToCheck = + enforceAccessControl?.objectsRequiringPrivilegeCheck?.objects?.filter( + (obj) => obj.requiresManageAccessControl + ) ?? []; + + const typesRequiringPrivilegeCheck = + enforceAccessControl?.objectsRequiringPrivilegeCheck?.types ?? new Set(); + const unauthorizedTypes = new Set(); + const inaccessibleTypes = new Set(); + const inaccessibleObjects = new Set(); - if (authzAction) { - for (const [type, spaces] of typesAndSpaces) { - const spacesArray = [...spaces]; + for (const [type, spaces] of typesAndSpaces) { + const spacesArray = [...spaces]; + if (authzAction) { if (!isAuthorizedInAllSpaces(type, authzAction as A, spacesArray, typeMap)) { unauthorizedTypes.add(type); } } + if (typesRequiringPrivilegeCheck.has(type)) { + if ( + !isAuthorizedInAllSpaces(type, MANAGE_ACCESS_CONTROL_ACTION as A, spacesArray, typeMap) + ) { + inaccessibleTypes.add(type); + accessControlObjectsToCheck + ?.filter((obj) => obj.type === type && obj.requiresManageAccessControl) + .forEach((obj) => inaccessibleObjects.add(obj)); + } + } } - if (unauthorizedTypes.size > 0) { - const targetTypes = [...unauthorizedTypes].sort().join(','); - const msg = `Unable to ${authzAction} ${targetTypes}`; + const allRequestedObjectAreInaccessible = this.allRequestedObjectAreInaccessible( + typesAndSpaces, + inaccessibleTypes, + allAccessControlObjects, + inaccessibleObjects + ); + + if (unauthorizedTypes.size > 0 || allRequestedObjectAreInaccessible) { + const uniqueTypes = new Set([...unauthorizedTypes, ...inaccessibleTypes]); + const targetTypes = [...uniqueTypes].sort().join(','); + const inaccessibleObjectsString = [...inaccessibleObjects] + .map((obj) => `${obj.type}:${obj.id}`) + .sort() + .join(','); + const msg = `Unable to ${authzAction} ${targetTypes}${ + inaccessibleObjects.size > 0 + ? ', access control restrictions for ' + inaccessibleObjectsString + : '' + }`; + // if we are bypassing all auditing, or bypassing failure auditing, do not log the event const error = this.errors.decorateForbiddenError(new Error(msg)); if (auditAction && bypass !== 'always' && bypass !== 'on_failure') { this.auditHelper({ @@ -594,6 +725,8 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten deleteFromSpaces, }); } + + return inaccessibleObjects; } /** @@ -611,7 +744,7 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten */ async authorize( params: InternalAuthorizeParams - ): Promise> { + ): Promise> { if (params.actions.size === 0) { throw new Error('No actions specified for authorization'); } @@ -622,28 +755,53 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten throw new Error('No spaces specified for authorization'); } + const currentUser = this.getCurrentUserFunc(); + this.accessControlService.setUserForOperation(currentUser); + const { authzActions } = this.translateActions(params.actions); + const accessControlObjects = params.auditOptions?.objects?.filter(({ type }) => + this.typeRegistry?.supportsAccessControl(type) + ); + const objectsRequiringPrivilegeCheck = + this.accessControlService.getObjectsRequiringPrivilegeCheck({ + objects: accessControlObjects || [], + actions: params.actions, + }); + const checkResult: CheckAuthorizationResult = await this.checkAuthorization({ types: params.types, spaces: params.spaces, actions: authzActions, - options: { allowGlobalResource: params.options?.allowGlobalResource === true }, + options: { + allowGlobalResource: params.options?.allowGlobalResource === true, + ...(objectsRequiringPrivilegeCheck.types.size > 0 && { + typesRequiringAccessControl: objectsRequiringPrivilegeCheck.types, + }), + }, }); const typesAndSpaces = params.enforceMap; + + const allInaccessibleObjects = new Set(); if (typesAndSpaces !== undefined && checkResult) { params.actions.forEach((action) => { - this.enforceAuthorization({ + const inaccessibleObjects = this.enforceAuthorization({ typesAndSpaces, action, typeMap: checkResult.typeMap, auditOptions: params.auditOptions, + ...(objectsRequiringPrivilegeCheck.objects?.length > 0 && { + enforceAccessControl: { + objectsRequiringPrivilegeCheck, + }, + }), }); + inaccessibleObjects.forEach((obj) => allInaccessibleObjects.add(obj)); }); } - return checkResult; + return { ...checkResult, inaccessibleObjects: allInaccessibleObjects }; } private maybeRedactSavedObject( @@ -664,6 +822,7 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten savedObject: this.maybeRedactSavedObject(savedObject), ...rest, }); + this.auditLogger.log(auditEvent); } } @@ -698,7 +857,7 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten async authorizeCreate( params: AuthorizeCreateParams - ): Promise> { + ): Promise> { return this.internalAuthorizeCreate({ namespace: params.namespace, objects: [params.object], @@ -707,14 +866,14 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten async authorizeBulkCreate( params: AuthorizeBulkCreateParams - ): Promise> { + ): Promise> { return this.internalAuthorizeCreate(params, { forceBulkAction: true }); } private async internalAuthorizeCreate( params: AuthorizeBulkCreateParams, options?: InternalAuthorizeOptions - ): Promise> { + ): Promise> { const namespaceString = SavedObjectsUtils.namespaceIdToString(params.namespace); const { objects } = params; @@ -763,7 +922,7 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten async authorizeUpdate( params: AuthorizeUpdateParams - ): Promise> { + ): Promise> { return this.internalAuthorizeUpdate({ namespace: params.namespace, objects: [params.object], @@ -772,14 +931,14 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten async authorizeBulkUpdate( params: AuthorizeBulkUpdateParams - ): Promise> { + ): Promise> { return this.internalAuthorizeUpdate(params, { forceBulkAction: true }); } private async internalAuthorizeUpdate( params: AuthorizeBulkUpdateParams, options?: InternalAuthorizeOptions - ): Promise> { + ): Promise> { const namespaceString = SavedObjectsUtils.namespaceIdToString(params.namespace); const { objects } = params; @@ -824,7 +983,7 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten async authorizeDelete( params: AuthorizeDeleteParams - ): Promise> { + ): Promise> { return this.internalAuthorizeDelete({ namespace: params.namespace, // delete params does not contain existingNamespaces because authz @@ -836,14 +995,14 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten async authorizeBulkDelete( params: AuthorizeBulkDeleteParams - ): Promise> { + ): Promise> { return this.internalAuthorizeDelete(params, { forceBulkAction: true }); } private async internalAuthorizeDelete( params: AuthorizeBulkDeleteParams, options?: InternalAuthorizeOptions - ): Promise> { + ): Promise> { const namespaceString = SavedObjectsUtils.namespaceIdToString(params.namespace); const { objects } = params; const enforceMap = new Map>(); @@ -865,7 +1024,7 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten } } - return this.authorize({ + return await this.authorize({ actions: new Set([action]), types: new Set(enforceMap.keys()), spaces: spacesToAuthorize, @@ -878,7 +1037,7 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten async authorizeGet( params: AuthorizeGetParams - ): Promise> { + ): Promise> { const { namespace, object, objectNotFound } = params; const spacesToEnforce = new Set([SavedObjectsUtils.namespaceIdToString(namespace)]); // Always check/enforce authZ for the active space const existingNamespaces = object.existingNamespaces; @@ -894,7 +1053,7 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten async authorizeBulkGet( params: AuthorizeBulkGetParams - ): Promise> { + ): Promise> { const action = SecurityAction.BULK_GET; const namespace = SavedObjectsUtils.namespaceIdToString(params.namespace); const { objects } = params; @@ -953,7 +1112,7 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten async authorizeCheckConflicts( params: AuthorizeCheckConflictsParams - ): Promise> { + ): Promise> { const action = SecurityAction.CHECK_CONFLICTS; const { namespace, objects } = params; this.assertObjectsArrayNotEmpty(objects, action); @@ -978,7 +1137,7 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten async authorizeRemoveReferences( params: AuthorizeDeleteParams - ): Promise> { + ): Promise> { // TODO: Improve authorization and auditing (https://github.com/elastic/kibana/issues/135259) const { namespace, object } = params; const spaces = new Set([SavedObjectsUtils.namespaceIdToString(namespace)]); // Always check/enforce authZ for the active space @@ -993,7 +1152,7 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten async authorizeOpenPointInTime( params: AuthorizeOpenPointInTimeParams - ): Promise> { + ): Promise> { const { namespaces, types } = params; const preAuthorizationResult = await this.authorize({ @@ -1021,6 +1180,109 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten return preAuthorizationResult; } + async authorizeChangeAccessControl( + params: AuthorizeChangeAccessControlParams, + operation: 'changeAccessMode' | 'changeOwnership' + ): Promise> { + const action = + operation === 'changeAccessMode' + ? SecurityAction.CHANGE_ACCESS_MODE + : SecurityAction.CHANGE_OWNERSHIP; + return await this.internalAuthorizeChangeAccessControl( + { + namespace: params.namespace, + objects: params.objects, + }, + action + ); + } + + async internalAuthorizeChangeAccessControl( + params: AuthorizeChangeAccessControlParams, + action: SecurityAction + ): Promise> { + if (!this.typeRegistry) { + throw new Error('Type registry is not defined'); + } + + this.accessControlService.setUserForOperation(this.getCurrentUserFunc()); + const namespaceString = SavedObjectsUtils.namespaceIdToString(params.namespace); + const { objects } = params; + const { auditAction, authzAction } = this.decodeSecurityAction(action); + + this.assertObjectsArrayNotEmpty(objects, action); + + const objectsNotSupportingAccessControl = objects.every( + ({ type }) => !this.typeRegistry?.supportsAccessControl(type) + ); + + if (objectsNotSupportingAccessControl) { + const errMessage = `Unable to ${authzAction} for types ${[ + ...objects.map(({ type }) => type), + ].join(', ')}`; + throw SavedObjectsErrorHelpers.decorateBadRequestError(new Error(errMessage)); + } + + const spacesToAuthorize = new Set([namespaceString]); + + const { types: typesRequiringAccessControl } = + this.accessControlService.getObjectsRequiringPrivilegeCheck({ + objects, + actions: new Set([action]), + }); + + /** + * AccessControl operations do not fall under the regular authorization actions for + * Saved Objects, but still require authorization. Hence, we pass an empty actions list to the base + * authorization checks. + */ + + let authorizationResult: CheckAuthorizationResult; + if (typesRequiringAccessControl.size > 0) { + authorizationResult = await this.checkAuthorization({ + types: new Set(typesRequiringAccessControl), + spaces: spacesToAuthorize, + actions: new Set([]), + options: { allowGlobalResource: true, typesRequiringAccessControl }, + }); + + /** + * enforceAccessControl acts only on the current space and is only either fully_authorized or unauthorized. + * Even though this is a bulk operation, we don't allow partial changes to access control, i.e the incoming + * list of SOs must all support access control. + */ + this.accessControlService.enforceAccessControl({ + typesRequiringAccessControl, + authorizationResult, + currentSpace: namespaceString, + addAuditEventFn: (types: string[]) => { + const errMessage = `Unable to ${authzAction} for types ${types.join(', ')}`; + const err = new Error(errMessage); + this.addAuditEvent({ + action: auditAction!, + error: err, + unauthorizedTypes: [...typesRequiringAccessControl], + unauthorizedSpaces: [...spacesToAuthorize], + }); + }, + }); + } else { + authorizationResult = { + status: 'fully_authorized', + typeMap: new Map(), + }; + } + if (auditAction) { + this.auditHelper({ + action: auditAction, + objects, + useSuccessOutcome: true, + }); + } + + return authorizationResult; + } + auditClosePointInTime() { this.addAuditEvent({ action: AuditAction.CLOSE_POINT_IN_TIME, @@ -1095,7 +1357,7 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten // Is the user authorized to access this object in this space? let isAuthorizedForObject = true; try { - this.enforceAuthorization({ + await this.enforceAuthorization({ typesAndSpaces: new Map([[type, new Set([namespaceString])]]), action, typeMap, @@ -1112,6 +1374,7 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten } return getIsAuthorizedForInboundReference(inbound); }); + // If the user is not authorized to access at least one inbound reference of this object, then we should omit this object. const isAuthorizedForGraph = requestedObjectsSet.has(objKey) || // If true, this is one of the requested objects, and we checked authorization above @@ -1181,7 +1444,10 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten const getRedactedSpaces = (spacesArray: string[] | undefined) => { if (!spacesArray) return; const savedObject = { type, namespaces: spacesArray } as SavedObject; // Other SavedObject attributes aren't required - const result = this.redactNamespaces({ savedObject, typeMap }); + const result = this.redactNamespaces({ + typeMap, + savedObject, + }); return result.namespaces; }; const redactedSpaces = getRedactedSpaces(spaces)!; @@ -1216,7 +1482,7 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten for (const result of objects) { let auditableObject: SavedObjectAudit | undefined; - if (isBulkResolveError(result)) { + if (SavedObjectsErrorHelpers.isBulkResolveError(result)) { const { type, id, error } = result; if (!SavedObjectsErrorHelpers.isBadRequestError(error)) { // Only "not found" errors should show up as audit events (not "unsupported type" errors) @@ -1258,7 +1524,7 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten }); return objects.map((result) => { - if (isBulkResolveError(result)) { + if (SavedObjectsErrorHelpers.isBulkResolveError(result)) { return result; } @@ -1430,6 +1696,66 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten includeSavedObjectNames() { return this.auditLogger.includeSavedObjectNames; } + + /** + * Filters out objects that are inaccessible due to access control restrictions during bulk actions. + * If an object is found in the `inaccessibleObjects` array, returns a left error result for that object. + * Otherwise, returns the original expected result with an updated esRequestIndex. + * + * @template L - Left (error) type + * @template R - Right (success) type + * @param expectedResults - Array of Either results (left: error, right: valid object) + * @param inaccessibleObjects - Array of objects that are inaccessible due to access control + * @returns Array of Either with inaccessible objects converted to error results + */ + async filterInaccessibleObjectsForBulkAction< + L extends { type: string; id?: string; error: Payload }, + R extends { type: string; id: string; esRequestIndex?: number } + >( + expectedResults: Array>, + inaccessibleObjects: Array<{ type: string; id: string }>, + action: 'bulk_create' | 'bulk_update' | 'bulk_delete', + reindex?: boolean + ): Promise>> { + let reIndexCounter = 0; + const verbMap = new Map([ + ['bulk_create', 'Overwriting'], // inaccessible objects during create can only be a result of overwriting + ['bulk_update', 'Updating'], + ['bulk_delete', 'Deleting'], + ]); + return Promise.all( + expectedResults.map(async (result) => { + if (isLeft(result)) { + return result; + } + if ( + inaccessibleObjects.find( + (obj) => obj.type === result.value.type && obj.id === result.value.id + ) + ) { + return left({ + id: result.value.id, + type: result.value.type, + error: { + ...errorContent( + SavedObjectsErrorHelpers.decorateForbiddenError( + new Error( + `${ + verbMap.get(action) ?? 'Affecting' + } objects in "write_restricted" mode that are owned by another user requires the "manage_access_control" privilege.` + ) + ) + ), + }, + } as L); + } + return { + ...result, + ...(reindex && { value: { ...result.value, esRequestIndex: reIndexCounter++ } }), + } as Either; + }) + ); + } } /** diff --git a/x-pack/platform/plugins/shared/security/server/saved_objects/types.ts b/x-pack/platform/plugins/shared/security/server/saved_objects/types.ts new file mode 100644 index 0000000000000..4c3e870022986 --- /dev/null +++ b/x-pack/platform/plugins/shared/security/server/saved_objects/types.ts @@ -0,0 +1,32 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * The SecurityAction enumeration contains values for all valid shared object + * security actions. The string for each value correlates to the ES operation. + */ +export enum SecurityAction { + CHECK_CONFLICTS, + CLOSE_POINT_IN_TIME, + COLLECT_MULTINAMESPACE_REFERENCES, + COLLECT_MULTINAMESPACE_REFERENCES_UPDATE_SPACES, + CREATE, + BULK_CREATE, + DELETE, + BULK_DELETE, + FIND, + GET, + BULK_GET, + INTERNAL_BULK_RESOLVE, + OPEN_POINT_IN_TIME, + REMOVE_REFERENCES, + UPDATE, + BULK_UPDATE, + UPDATE_OBJECTS_SPACES, + CHANGE_OWNERSHIP, + CHANGE_ACCESS_MODE, +} diff --git a/x-pack/platform/plugins/shared/security/tsconfig.json b/x-pack/platform/plugins/shared/security/tsconfig.json index c338969c8c818..50a9015367bf2 100644 --- a/x-pack/platform/plugins/shared/security/tsconfig.json +++ b/x-pack/platform/plugins/shared/security/tsconfig.json @@ -49,7 +49,6 @@ "@kbn/utility-types-jest", "@kbn/es-errors", "@kbn/logging", - "@kbn/core-saved-objects-api-server-internal", "@kbn/core-saved-objects-api-server-mocks", "@kbn/logging-mocks", "@kbn/core-saved-objects-utils-server", @@ -94,6 +93,7 @@ "@kbn/core-user-profile-browser-mocks", "@kbn/react-kibana-context-theme", "@kbn/css-utils", + "@kbn/core-saved-objects-base-server-mocks", "@kbn/licensing-types", "@kbn/lazy-object" ], diff --git a/x-pack/platform/plugins/shared/spaces/public/copy_saved_objects_to_space/lib/process_import_response.ts b/x-pack/platform/plugins/shared/spaces/public/copy_saved_objects_to_space/lib/process_import_response.ts index 061900f2b1777..7494b90925e5e 100644 --- a/x-pack/platform/plugins/shared/spaces/public/copy_saved_objects_to_space/lib/process_import_response.ts +++ b/x-pack/platform/plugins/shared/spaces/public/copy_saved_objects_to_space/lib/process_import_response.ts @@ -12,6 +12,7 @@ import type { SavedObjectsImportMissingReferencesError, SavedObjectsImportResponse, SavedObjectsImportSuccess, + SavedObjectsImportUnexpectedAccessControlMetadataError, SavedObjectsImportUnknownError, SavedObjectsImportUnsupportedTypeError, } from '@kbn/core/public'; @@ -23,7 +24,8 @@ export interface FailedImport { | SavedObjectsImportAmbiguousConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError - | SavedObjectsImportUnknownError; + | SavedObjectsImportUnknownError + | SavedObjectsImportUnexpectedAccessControlMetadataError; } export interface ProcessedImportResponse { diff --git a/x-pack/platform/test/moon.yml b/x-pack/platform/test/moon.yml index 2bbc70d8c97ae..3ddb19305d2b8 100644 --- a/x-pack/platform/test/moon.yml +++ b/x-pack/platform/test/moon.yml @@ -157,6 +157,7 @@ dependsOn: - '@kbn/onechat-plugin' - '@kbn/field-formats-common' - '@kbn/reporting-test-routes' + - '@kbn/access-control-test-plugin' - '@kbn/alerting-types' - '@kbn/visualizations-common' - '@kbn/test-subj-selector' diff --git a/x-pack/platform/test/spaces_api_integration/access_control_objects/apis/spaces/access_control_objects.ts b/x-pack/platform/test/spaces_api_integration/access_control_objects/apis/spaces/access_control_objects.ts new file mode 100644 index 0000000000000..25aef0af509f5 --- /dev/null +++ b/x-pack/platform/test/spaces_api_integration/access_control_objects/apis/spaces/access_control_objects.ts @@ -0,0 +1,2481 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { parse as parseCookie } from 'tough-cookie'; + +import { + ACCESS_CONTROL_TYPE, + NON_ACCESS_CONTROL_TYPE, +} from '@kbn/access-control-test-plugin/server'; +import expect from '@kbn/expect'; +import { adminTestUser } from '@kbn/test'; + +import type { FtrProviderContext } from '../../../../functional/ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const es = getService('es'); + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const security = getService('security'); + + const createSimpleUser = async (roles: string[] = ['viewer']) => { + await es.security.putUser({ + username: 'simple_user', + refresh: 'wait_for', + password: 'changeme', + roles, + }); + }; + + const login = async (username: string, password: string | undefined) => { + const response = await supertestWithoutAuth + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username, password }, + }) + .expect(200); + const cookie = parseCookie(response.headers['set-cookie'][0])!; + const profileUidResponse = await supertestWithoutAuth + .get('/internal/security/me') + .set('Cookie', cookie.cookieString()) + .expect(200); + return { + cookie, + profileUid: profileUidResponse.body.profile_uid, + }; + }; + + const loginAsKibanaAdmin = () => login(adminTestUser.username, adminTestUser.password); + + const loginAsObjectOwner = (username: string, password: string) => login(username, password); + + const loginAsNotObjectOwner = (username: string, password: string) => login(username, password); + + const activateSimpleUserProfile = async () => { + const response = await es.security.activateUserProfile({ + username: 'simple_user', + password: 'changeme', + grant_type: 'password', + }); + + return { + profileUid: response.uid, + }; + }; + + describe('access control saved objects', () => { + before(async () => { + await security.testUser.setRoles(['kibana_savedobjects_editor']); + await createSimpleUser(); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + + describe('default state of access control objects', () => { + it('types supporting access control are created with default access mode when not specified', async () => { + const { cookie: adminCookie, profileUid } = await loginAsKibanaAdmin(); + const response = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + expect(response.body).to.have.property('accessControl'); + expect(response.body.accessControl).to.have.property('accessMode', 'default'); + expect(response.body.accessControl).to.have.property('owner', profileUid); + }); + }); + + describe('#create', () => { + it('should create a write-restricted object', async () => { + const { cookie: adminCookie, profileUid } = await loginAsKibanaAdmin(); + const response = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + + expect(response.body.type).to.eql(ACCESS_CONTROL_TYPE); + expect(response.body).to.have.property('accessControl'); + + const { accessControl } = response.body; + expect(accessControl).to.have.property('accessMode'); + expect(accessControl).to.have.property('owner'); + + const { owner, accessMode } = accessControl; + expect(accessMode).to.be('write_restricted'); + expect(owner).to.be(profileUid); + }); + + it('creates objects that support access control without metadata when there is no active user profile', async () => { + const response = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'xxxxx') + .set( + 'Authorization', + `Basic ${Buffer.from(`${adminTestUser.username}:${adminTestUser.password}`).toString( + 'base64' + )}` + ) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + expect(response.body).not.to.have.property('accessControl'); + expect(response.body).to.have.property('type'); + const { type } = response.body; + expect(type).to.be(ACCESS_CONTROL_TYPE); + const { id: createdId } = response.body; + + const getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${createdId}`) + .set('kbn-xsrf', 'true') + .set( + 'Authorization', + `Basic ${Buffer.from(`${adminTestUser.username}:${adminTestUser.password}`).toString( + 'base64' + )}` + ) + .expect(200); + expect(getResponse.body).not.to.have.property('accessControl'); + }); + + it('should throw when trying to create an access control object with no user', async () => { + const response = await supertest + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(400); + expect(response.body).to.have.property('message'); + expect(response.body.message).to.contain( + `Cannot create a saved object of type ${ACCESS_CONTROL_TYPE} with an access mode because Kibana could not determine the user profile ID for the caller. Access control requires an identifiable user profile: Bad Request` + ); + }); + + it('should allow overwriting an object owned by current user', async () => { + const { cookie: objectOwnerCookie, profileUid } = await loginAsObjectOwner( + 'test_user', + 'changeme' + ); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + + const objectId = createResponse.body.id; + expect(createResponse.body.attributes).to.have.property('description', 'test'); + { + expect(createResponse.body).to.have.property('accessControl'); + + const { accessControl } = createResponse.body; + expect(accessControl).to.have.property('accessMode'); + expect(accessControl).to.have.property('owner'); + + const { owner, accessMode } = accessControl; + expect(accessMode).to.be('write_restricted'); + expect(owner).to.be(profileUid); + } + + const overwriteResponse = await supertestWithoutAuth + .post('/access_control_objects/create?overwrite=true') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ id: objectId, type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + + const overwriteId = overwriteResponse.body.id; + expect(createResponse.body).to.have.property('id', overwriteId); + expect(createResponse.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + expect(createResponse.body.accessControl).to.have.property('owner', profileUid); + }); + + it('should allow overwriting an object owned by another user if admin', async () => { + const { cookie: objectOwnerCookie, profileUid: objectOnwerProfileUid } = + await loginAsObjectOwner('test_user', 'changeme'); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + + const objectId = createResponse.body.id; + expect(createResponse.body.attributes).to.have.property('description', 'test'); + expect(createResponse.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + expect(createResponse.body.accessControl).to.have.property('owner', objectOnwerProfileUid); + + const { cookie: adminCookie } = await loginAsKibanaAdmin(); + + const overwriteResponse = await supertestWithoutAuth + .post('/access_control_objects/create?overwrite=true') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ id: objectId, type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + + const overwriteId = overwriteResponse.body.id; + expect(createResponse.body).to.have.property('id', overwriteId); + expect(createResponse.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + expect(createResponse.body.accessControl).to.have.property('owner', objectOnwerProfileUid); + }); + + it('should allow overwriting an object owned by another user if in default mode', async () => { + const { cookie: adminCookie, profileUid: adminUid } = await loginAsKibanaAdmin(); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: false }) + .expect(200); + + const objectId = createResponse.body.id; + expect(createResponse.body.attributes).to.have.property('description', 'test'); + expect(createResponse.body.accessControl).to.have.property('accessMode', 'default'); + expect(createResponse.body.accessControl).to.have.property('owner', adminUid); + + const { cookie: otherUserCookie } = await loginAsNotObjectOwner('test_user', 'changeme'); + + const overwriteResponse = await supertestWithoutAuth + .post('/access_control_objects/create?overwrite=true') + .set('kbn-xsrf', 'true') + .set('cookie', otherUserCookie.cookieString()) + .send({ + id: objectId, + type: ACCESS_CONTROL_TYPE, + isWriteRestricted: true, + // description: 'overwritten', ToDo: support this in test plugin + }) + .expect(200); + + const overwriteId = overwriteResponse.body.id; + expect(createResponse.body).to.have.property('id', overwriteId); + // expect(createResponse.body.attributes).to.have.property('description', 'test'); + expect(createResponse.body.accessControl).to.have.property('accessMode', 'default'); // cannot overwrite mode + expect(createResponse.body.accessControl).to.have.property('owner', adminUid); + }); + + it('should reject when attempting to overwrite an object owned by another user if not admin', async () => { + const { cookie: objectOwnerCookie, profileUid: adminUid } = await loginAsKibanaAdmin(); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + + const objectId = createResponse.body.id; + expect(createResponse.body.attributes).to.have.property('description', 'test'); + expect(createResponse.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + expect(createResponse.body.accessControl).to.have.property('owner', adminUid); + + const { cookie: otherOwnerCookie } = await loginAsNotObjectOwner('test_user', 'changeme'); + + const overwriteResponse = await supertestWithoutAuth + .post('/access_control_objects/create?overwrite=true') + .set('kbn-xsrf', 'true') + .set('cookie', otherOwnerCookie.cookieString()) + .send({ id: objectId, type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(403); + + expect(overwriteResponse.body).to.have.property('error', 'Forbidden'); + expect(overwriteResponse.body).to.have.property( + 'message', + `Unable to create ${ACCESS_CONTROL_TYPE}, access control restrictions for ${ACCESS_CONTROL_TYPE}:${objectId}` + ); + }); + }); + + describe('#bulk_create', () => { + describe('success', () => { + it('should create write-restricted objects', async () => { + const { cookie: objectOwnerCookie, profileUid: objectOwnerProfileUid } = + await loginAsObjectOwner('test_user', 'changeme'); + + const bulkCreateResponse = await supertestWithoutAuth + .post('/access_control_objects/bulk_create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ + objects: [ + { type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }, + { type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }, + ], + }); + expect(bulkCreateResponse.body.saved_objects).to.have.length(2); + for (const { accessControl } of bulkCreateResponse.body.saved_objects) { + expect(accessControl).to.have.property('owner', objectOwnerProfileUid); + expect(accessControl).to.have.property('accessMode', 'write_restricted'); + } + }); + + it('allows owner to overwrite objects they own', async () => { + const { cookie: objectOwnerCookie, profileUid: objectOwnerProfileUid } = + await loginAsObjectOwner('test_user', 'changeme'); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + expect(firstObject.body.accessControl).to.have.property('owner', objectOwnerProfileUid); + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + expect(secondObject.body.accessControl).to.have.property('owner', objectOwnerProfileUid); + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_create?overwrite=true') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ + objects, + }) + .expect(200); + for (const { id, accessControl } of res.body.saved_objects) { + const object = objects.find((obj) => obj.id === id); + expect(object).to.not.be(undefined); + expect(accessControl).to.have.property('owner', objectOwnerProfileUid); + expect(accessControl).to.have.property('accessMode', 'write_restricted'); + } + }); + + it('allows non-owner to overwrite objects in default mode', async () => { + const { cookie: adminCookie, profileUid: adminProfileUid } = await loginAsKibanaAdmin(); + + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: false }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + expect(firstObject.body.accessControl).to.have.property('owner', adminProfileUid); + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: false }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + expect(secondObject.body.accessControl).to.have.property('owner', adminProfileUid); + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + const { cookie: notObjectOwnerCookieCookie } = await loginAsNotObjectOwner( + 'test_user', + 'changeme' + ); + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_create?overwrite=true') + .set('kbn-xsrf', 'true') + .set('cookie', notObjectOwnerCookieCookie.cookieString()) + .send({ + objects, + }) + .expect(200); + + expect(res.body.saved_objects.length).to.be(2); + expect(res.body.saved_objects[0].accessControl).to.have.property( + 'owner', + adminProfileUid + ); + expect(res.body.saved_objects[0].accessControl).to.have.property('accessMode', 'default'); + expect(res.body.saved_objects[1].accessControl).to.have.property( + 'owner', + adminProfileUid + ); + expect(res.body.saved_objects[1].accessControl).to.have.property('accessMode', 'default'); + }); + + it('allows admin to overwrite objects they do not own', async () => { + const { cookie: objectOwnerCookie, profileUid: objectOwnerProfileUid } = + await loginAsObjectOwner('test_user', 'changeme'); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + expect(firstObject.body.accessControl).to.have.property('owner', objectOwnerProfileUid); + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + expect(secondObject.body.accessControl).to.have.property('owner', objectOwnerProfileUid); + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + const { cookie: adminCookie } = await loginAsKibanaAdmin(); + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_create?overwrite=true') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ + objects, + }) + .expect(200); + for (const { id, accessControl } of res.body.saved_objects) { + const object = objects.find((obj) => obj.id === id); + expect(object).to.not.be(undefined); + expect(accessControl).to.have.property('owner', objectOwnerProfileUid); + expect(accessControl).to.have.property('accessMode', 'write_restricted'); + } + }); + }); + + describe('failure modes', () => { + it('rejects when overwriting and all objects are write-restricted and inaccessible', async () => { + const { cookie: adminCookie, profileUid: adminProfileUid } = await loginAsKibanaAdmin(); + + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + expect(firstObject.body.accessControl).to.have.property('owner', adminProfileUid); + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + expect(secondObject.body.accessControl).to.have.property('owner', adminProfileUid); + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + const { cookie: notObjectOwnerCookieCookie } = await loginAsNotObjectOwner( + 'test_user', + 'changeme' + ); + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_create?overwrite=true') + .set('kbn-xsrf', 'true') + .set('cookie', notObjectOwnerCookieCookie.cookieString()) + .send({ + objects, + }) + .expect(403); + + expect(res.body).to.have.property('error', 'Forbidden'); + expect(res.body).to.have.property('message'); + expect(res.body.message).to.contain( + `Unable to bulk_create ${ACCESS_CONTROL_TYPE}, access control restrictions for` + ); + expect(res.body.message).to.contain(`${ACCESS_CONTROL_TYPE}:${objectId1}`); // order is not guaranteed + expect(res.body.message).to.contain(`${ACCESS_CONTROL_TYPE}:${objectId2}`); + + const getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${objectId1}`) + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .expect(200); + expect(getResponse.body.accessControl).to.have.property('owner', adminProfileUid); + expect(getResponse.body.accessControl).to.have.property('accessMode', 'write_restricted'); + + const getResponse2 = await supertestWithoutAuth + .get(`/access_control_objects/${objectId2}`) + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .expect(200); + expect(getResponse2.body.accessControl).to.have.property('owner', adminProfileUid); + expect(getResponse2.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + }); + + it('return status when overwriting objects and all objects are write-restricted but some are owned by current user', async () => { + const { cookie: adminCookie, profileUid: adminProfileUid } = await loginAsKibanaAdmin(); + + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + expect(firstObject.body).to.have.property('accessControl'); + expect(firstObject.body.accessControl).to.have.property('owner', adminProfileUid); + + const { cookie: notObjectOwnerCookieCookie, profileUid: nonAdminProfileUid } = + await loginAsNotObjectOwner('test_user', 'changeme'); + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', notObjectOwnerCookieCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + expect(secondObject.body).to.have.property('accessControl'); + expect(secondObject.body.accessControl).to.have.property('owner', nonAdminProfileUid); + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_create?overwrite=true') + .set('kbn-xsrf', 'true') + .set('cookie', notObjectOwnerCookieCookie.cookieString()) + .send({ + objects, + }) + .expect(200); + + expect(res.body).to.have.property('saved_objects'); + expect(res.body.saved_objects).to.be.an('array'); + expect(res.body.saved_objects).to.have.length(2); + expect(res.body.saved_objects[0]).to.eql({ + id: objectId1, + type: ACCESS_CONTROL_TYPE, + error: { + statusCode: 403, + error: 'Forbidden', + message: + 'Overwriting objects in "write_restricted" mode that are owned by another user requires the "manage_access_control" privilege.', + }, + }); + expect(res.body.saved_objects[1]).to.have.property('type', ACCESS_CONTROL_TYPE); + expect(res.body.saved_objects[1]).to.have.property('id', objectId2); + expect(res.body.saved_objects[1]).not.to.have.property('error'); + }); + + it('return status when overwriting objects and some objects are in default mode', async () => { + const { cookie: adminCookie, profileUid: adminProfileUid } = await loginAsKibanaAdmin(); + + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + expect(firstObject.body).to.have.property('accessControl'); + expect(firstObject.body.accessControl).to.have.property('owner', adminProfileUid); + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: false }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + expect(secondObject.body).to.have.property('accessControl'); + expect(secondObject.body.accessControl).to.have.property('owner', adminProfileUid); + + const { cookie: notObjectOwnerCookieCookie } = await loginAsNotObjectOwner( + 'test_user', + 'changeme' + ); + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_create?overwrite=true') + .set('kbn-xsrf', 'true') + .set('cookie', notObjectOwnerCookieCookie.cookieString()) + .send({ + objects, + }) + .expect(200); + + expect(res.body).to.have.property('saved_objects'); + expect(res.body.saved_objects).to.be.an('array'); + expect(res.body.saved_objects).to.have.length(2); + expect(res.body.saved_objects[0]).to.eql({ + id: objectId1, + type: ACCESS_CONTROL_TYPE, + error: { + statusCode: 403, + error: 'Forbidden', + message: + 'Overwriting objects in "write_restricted" mode that are owned by another user requires the "manage_access_control" privilege.', + }, + }); + expect(res.body.saved_objects[1]).to.have.property('type', ACCESS_CONTROL_TYPE); + expect(res.body.saved_objects[1]).to.have.property('id', objectId2); + expect(res.body.saved_objects[1]).not.to.have.property('error'); + }); + + it('return stauts when overwriting and some authorized types do not support access control', async () => { + const { cookie: adminCookie, profileUid: adminProfileUid } = await loginAsKibanaAdmin(); + + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + expect(firstObject.body).to.have.property('accessControl'); + expect(firstObject.body.accessControl).to.have.property('owner', adminProfileUid); + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ type: NON_ACCESS_CONTROL_TYPE }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + expect(secondObject.body).not.to.have.property('accessControl'); + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + const { cookie: notObjectOwnerCookieCookie } = await loginAsNotObjectOwner( + 'test_user', + 'changeme' + ); + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_create?overwrite=true') + .set('kbn-xsrf', 'true') + .set('cookie', notObjectOwnerCookieCookie.cookieString()) + .send({ + objects, + }) + .expect(200); + + expect(res.body).to.have.property('saved_objects'); + expect(res.body.saved_objects).to.be.an('array'); + expect(res.body.saved_objects).to.have.length(2); + expect(res.body.saved_objects[0]).to.eql({ + id: objectId1, + type: ACCESS_CONTROL_TYPE, + error: { + statusCode: 403, + error: 'Forbidden', + message: + 'Overwriting objects in "write_restricted" mode that are owned by another user requires the "manage_access_control" privilege.', + }, + }); + expect(res.body.saved_objects[1]).to.have.property('type', NON_ACCESS_CONTROL_TYPE); + expect(res.body.saved_objects[1]).to.have.property('id', objectId2); + expect(res.body.saved_objects[1]).not.to.have.property('error'); + }); + }); + }); + + describe('#update', () => { + it('should update write-restricted objects owned by the same user', async () => { + const { cookie: objectOwnerCookie, profileUid } = await loginAsObjectOwner( + 'test_user', + 'changeme' + ); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + + const objectId = createResponse.body.id; + expect(createResponse.body.attributes).to.have.property('description', 'test'); + expect(createResponse.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + expect(createResponse.body.accessControl).to.have.property('owner', profileUid); + + const updateResponse = await supertestWithoutAuth + .put('/access_control_objects/update') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ objectId, type: ACCESS_CONTROL_TYPE }) + .expect(200); + + expect(updateResponse.body.id).to.eql(objectId); + expect(updateResponse.body.attributes).to.have.property( + 'description', + 'updated description' + ); + }); + + it('should throw when updating write-restricted objects owned by a different user when not admin', async () => { + const { cookie: adminCookie, profileUid: adminProfileUid } = await loginAsKibanaAdmin(); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const objectId = createResponse.body.id; + expect(createResponse.body.attributes).to.have.property('description', 'test'); + expect(createResponse.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + expect(createResponse.body.accessControl).to.have.property('owner', adminProfileUid); + + const { cookie: notOwnerCookie } = await loginAsNotObjectOwner('test_user', 'changeme'); + const updateResponse = await supertestWithoutAuth + .put('/access_control_objects/update') + .set('kbn-xsrf', 'true') + .set('cookie', notOwnerCookie.cookieString()) + .send({ objectId, type: ACCESS_CONTROL_TYPE }) + .expect(403); + expect(updateResponse.body).to.have.property('message'); + expect(updateResponse.body.message).to.contain(`Unable to update ${ACCESS_CONTROL_TYPE}`); + }); + + it('objects with default accessMode can be modified by non-owners', async () => { + const { cookie: adminCookie } = await loginAsKibanaAdmin(); + const response = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + const objectId = response.body.id; + + await createSimpleUser(['kibana_savedobjects_editor']); + const { cookie: notOwnerCookie } = await loginAsNotObjectOwner('simple_user', 'changeme'); + const updateResponse = await supertestWithoutAuth + .put('/access_control_objects/update') + .set('kbn-xsrf', 'true') + .set('cookie', notOwnerCookie.cookieString()) + .send({ objectId, type: ACCESS_CONTROL_TYPE }); + + expect(updateResponse.body.id).to.eql(objectId); + expect(updateResponse.body.attributes).to.have.property( + 'description', + 'updated description' + ); + }); + + it('allows admin to update objects owned by different user', async () => { + const { cookie: ownerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const objectId = createResponse.body.id; + + const { cookie: adminCookie } = await loginAsKibanaAdmin(); + const updateResponse = await supertestWithoutAuth + .put('/access_control_objects/update') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ objectId, type: ACCESS_CONTROL_TYPE }) + .expect(200); + + expect(updateResponse.body.id).to.eql(objectId); + expect(updateResponse.body.attributes).to.have.property( + 'description', + 'updated description' + ); + }); + }); + + describe('#bulk_update', () => { + describe('success', () => { + it('allows owner to bulk update objects marked as write restricted', async () => { + const { cookie: objectOwnerCookie, profileUid: objectOwnerProfileUid } = + await loginAsObjectOwner('test_user', 'changeme'); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_update') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ + objects, + }) + .expect(200); + for (const { id, attributes, accessControl } of res.body.saved_objects) { + const object = objects.find((obj) => obj.id === id); + expect(object).to.not.be(undefined); + expect(attributes).to.have.property('description', 'updated description'); + expect(accessControl).to.have.property('owner', objectOwnerProfileUid); + expect(accessControl).to.have.property('accessMode', 'write_restricted'); + } + }); + + it('allows admin to bulk update objects marked as write restricted', async () => { + const { cookie: objectOwnerCookie, profileUid: objectOwnerProfileUid } = + await loginAsObjectOwner('test_user', 'changeme'); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + const { cookie: adminCookie } = await loginAsKibanaAdmin(); + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_update') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ + objects, + }) + .expect(200); + for (const { id, attributes, accessControl } of res.body.saved_objects) { + const object = objects.find((obj) => obj.id === id); + expect(object).to.not.be(undefined); + expect(attributes).to.have.property('description', 'updated description'); + expect(accessControl).to.have.property('owner', objectOwnerProfileUid); + expect(accessControl).to.have.property('accessMode', 'write_restricted'); + } + }); + + it('allows non-owner non-admin to bulk update objects in default mode ', async () => { + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + await createSimpleUser(['kibana_savedobjects_editor']); + const { cookie: notOwnerCookie } = await loginAsNotObjectOwner('simple_user', 'changeme'); + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_update') + .set('kbn-xsrf', 'true') + .set('cookie', notOwnerCookie.cookieString()) + .send({ + objects, + }) + .expect(200); + for (const { id, attributes, accessControl } of res.body.saved_objects) { + const object = objects.find((obj) => obj.id === id); + expect(object).to.not.be(undefined); + expect(attributes).to.have.property('description', 'updated description'); + expect(accessControl).to.have.property('accessMode', 'default'); + } + }); + }); + + describe('failuere modes', () => { + it('rejects if all objects are write-restricted and inaccessible', async () => { + await activateSimpleUserProfile(); + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + const { cookie: nonOwnerCookie } = await loginAsNotObjectOwner('simple_user', 'changeme'); + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_update') + .set('kbn-xsrf', 'true') + .set('cookie', nonOwnerCookie.cookieString()) + .send({ + objects, + }) + .expect(403); + expect(res.body).to.have.property('message'); + expect(res.body.message).to.contain( + `Unable to bulk_update ${ACCESS_CONTROL_TYPE}, access control restrictions for ${ACCESS_CONTROL_TYPE}:` + ); + expect(res.body.message).to.contain(`${ACCESS_CONTROL_TYPE}:${objectId1}`); + expect(res.body.message).to.contain(`${ACCESS_CONTROL_TYPE}:${objectId2}`); + }); + + it('returns status if all objects are write-restricted but some are owned by the current user', async () => { + await activateSimpleUserProfile(); + const { cookie: object1OwnerCookie, profileUid: obj1OwnerId } = await loginAsObjectOwner( + 'test_user', + 'changeme' + ); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', object1OwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + expect(firstObject.body).to.have.property('accessControl'); + expect(firstObject.body.accessControl).to.have.property('owner', obj1OwnerId); + expect(firstObject.body.accessControl).to.have.property('accessMode', 'write_restricted'); + + await createSimpleUser(['kibana_savedobjects_editor']); + const { cookie: object2OwnerCookie, profileUid: obj2OwnerId } = + await loginAsNotObjectOwner('simple_user', 'changeme'); + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', object2OwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + expect(secondObject.body).to.have.property('accessControl'); + expect(secondObject.body.accessControl).to.have.property('owner', obj2OwnerId); + expect(secondObject.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_update') + .set('kbn-xsrf', 'true') + .set('cookie', object2OwnerCookie.cookieString()) + .send({ + objects, + }) + .expect(200); + + expect(res.body).to.have.property('saved_objects'); + expect(res.body.saved_objects).to.be.an('array'); + expect(res.body.saved_objects).to.have.length(2); + expect(res.body.saved_objects[0]).to.eql({ + id: objectId1, + type: ACCESS_CONTROL_TYPE, + error: { + statusCode: 403, + error: 'Forbidden', + message: + 'Updating objects in "write_restricted" mode that are owned by another user requires the "manage_access_control" privilege.', + }, + }); + expect(res.body.saved_objects[1]).to.have.property('id', objectId2); + expect(res.body.saved_objects[1]).to.have.property('type', type2); + expect(res.body.saved_objects[1]).to.have.property('updated_by', obj2OwnerId); + expect(res.body.saved_objects[1]).not.to.have.property('error'); + }); + + it('returns status if some objects are in default mode', async () => { + await activateSimpleUserProfile(); + const { cookie: object1OwnerCookie, profileUid: obj1OwnerId } = await loginAsObjectOwner( + 'test_user', + 'changeme' + ); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', object1OwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + expect(firstObject.body).to.have.property('accessControl'); + expect(firstObject.body.accessControl).to.have.property('owner', obj1OwnerId); + expect(firstObject.body.accessControl).to.have.property('accessMode', 'write_restricted'); + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', object1OwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: false }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + expect(secondObject.body).to.have.property('accessControl'); + expect(secondObject.body.accessControl).to.have.property('owner', obj1OwnerId); + expect(secondObject.body.accessControl).to.have.property('accessMode', 'default'); + + await createSimpleUser(['kibana_savedobjects_editor']); + const { cookie: object2OwnerCookie, profileUid: obj2OwnerId } = + await loginAsNotObjectOwner('simple_user', 'changeme'); + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_update') + .set('kbn-xsrf', 'true') + .set('cookie', object2OwnerCookie.cookieString()) + .send({ + objects, + }) + .expect(200); + + expect(res.body).to.have.property('saved_objects'); + expect(res.body.saved_objects).to.be.an('array'); + expect(res.body.saved_objects).to.have.length(2); + expect(res.body.saved_objects[0]).to.eql({ + id: objectId1, + type: ACCESS_CONTROL_TYPE, + error: { + statusCode: 403, + error: 'Forbidden', + message: + 'Updating objects in "write_restricted" mode that are owned by another user requires the "manage_access_control" privilege.', + }, + }); + expect(res.body.saved_objects[1]).to.have.property('id', objectId2); + expect(res.body.saved_objects[1]).to.have.property('type', type2); + expect(res.body.saved_objects[1]).to.have.property('updated_by', obj2OwnerId); + expect(res.body.saved_objects[1]).not.to.have.property('error'); + }); + + it('returns status if some authorized types do not support access control', async () => { + await activateSimpleUserProfile(); + const { cookie: object1OwnerCookie, profileUid: obj1OwnerId } = await loginAsObjectOwner( + 'test_user', + 'changeme' + ); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', object1OwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + expect(firstObject.body).to.have.property('accessControl'); + expect(firstObject.body.accessControl).to.have.property('owner', obj1OwnerId); + expect(firstObject.body.accessControl).to.have.property('accessMode', 'write_restricted'); + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', object1OwnerCookie.cookieString()) + .send({ type: NON_ACCESS_CONTROL_TYPE }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + expect(secondObject.body).not.to.have.property('accessControl'); + + await createSimpleUser(['kibana_savedobjects_editor']); + const { cookie: object2OwnerCookie, profileUid: obj2OwnerId } = + await loginAsNotObjectOwner('simple_user', 'changeme'); + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_update') + .set('kbn-xsrf', 'true') + .set('cookie', object2OwnerCookie.cookieString()) + .send({ + objects, + }) + .expect(200); + + expect(res.body).to.have.property('saved_objects'); + expect(res.body.saved_objects).to.be.an('array'); + expect(res.body.saved_objects).to.have.length(2); + expect(res.body.saved_objects[0]).to.eql({ + id: objectId1, + type: ACCESS_CONTROL_TYPE, + error: { + statusCode: 403, + error: 'Forbidden', + message: + 'Updating objects in "write_restricted" mode that are owned by another user requires the "manage_access_control" privilege.', + }, + }); + expect(res.body.saved_objects[1]).to.have.property('id', objectId2); + expect(res.body.saved_objects[1]).to.have.property('type', type2); + expect(res.body.saved_objects[1]).to.have.property('updated_by', obj2OwnerId); + expect(res.body.saved_objects[1]).not.to.have.property('error'); + }); + }); + }); + + describe('#delete', () => { + it('allow owner to delete object marked as write-restricted', async () => { + const { cookie: objectOwnerCookie, profileUid } = await loginAsObjectOwner( + 'test_user', + 'changeme' + ); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const objectId = createResponse.body.id; + expect(createResponse.body.accessControl).to.have.property('owner', profileUid); + + await supertestWithoutAuth + .delete(`/access_control_objects/${objectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .expect(200); + }); + + it('allows admin to delete object marked as write-restricted', async () => { + const { cookie: objectOwnerCookie, profileUid } = await loginAsObjectOwner( + 'test_user', + 'changeme' + ); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const objectId = createResponse.body.id; + expect(createResponse.body.accessControl).to.have.property('owner', profileUid); + + const { cookie: adminCookie } = await loginAsKibanaAdmin(); + await supertestWithoutAuth + .delete(`/access_control_objects/${objectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .expect(200); + + const getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${objectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .expect(404); + expect(getResponse.body).to.have.property('message'); + expect(getResponse.body.message).to.contain( + `Saved object [${ACCESS_CONTROL_TYPE}/${objectId}] not found` + ); + }); + + it('throws when trying to delete write-restricted object owned by a different user when not admin', async () => { + const { cookie: adminCookie, profileUid: adminProfileUid } = await loginAsKibanaAdmin(); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const objectId = createResponse.body.id; + expect(createResponse.body.accessControl).to.have.property('owner', adminProfileUid); + + const { cookie: notOwnerCookie } = await loginAsNotObjectOwner('test_user', 'changeme'); + const deleteResponse = await supertestWithoutAuth + .delete(`/access_control_objects/${objectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', notOwnerCookie.cookieString()) + .expect(403); + expect(deleteResponse.body).to.have.property('message'); + expect(deleteResponse.body.message).to.contain(`Unable to delete ${ACCESS_CONTROL_TYPE}`); + }); + + it('allows non-owner to delete object in default mode', async () => { + const { cookie: ownerCookie } = await loginAsKibanaAdmin(); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + + const objectId = createResponse.body.id; + const { cookie: notOwnerCookie } = await loginAsNotObjectOwner('test_user', 'changeme'); + + await supertestWithoutAuth + .delete(`/access_control_objects/${objectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', notOwnerCookie.cookieString()) + .expect(200); + }); + }); + + describe('#bulk_delete', () => { + describe('bulk delete ownable objects', () => { + describe('success', () => { + it('allows owner to bulk delete objects in write-restricted mode', async () => { + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_delete') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ + objects, + }) + .expect(200); + + for (const { id, success } of res.body.statuses) { + const object = objects.find((obj) => obj.id === id); + expect(object).to.not.be(undefined); + expect(success).to.be(true); + } + }); + + it('allows non-owner to bulk delete objects in default mode', async () => { + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + await createSimpleUser(['kibana_savedobjects_editor']); + const { cookie: notOwnerCookie } = await loginAsNotObjectOwner( + 'simple_user', + 'changeme' + ); + + await supertestWithoutAuth + .post('/access_control_objects/bulk_delete') + .set('kbn-xsrf', 'true') + .set('cookie', notOwnerCookie.cookieString()) + .send({ objects }) + .expect(200); + + await supertestWithoutAuth + .get(`/access_control_objects/${objectId1}`) + .set('kbn-xsrf', 'true') + .set('cookie', notOwnerCookie.cookieString()) + .expect(404); + + await supertestWithoutAuth + .get(`/access_control_objects/${objectId2}`) + .set('kbn-xsrf', 'true') + .set('cookie', notOwnerCookie.cookieString()) + .expect(404); + }); + + it('allows admin to bulk delete objects they do not own', async () => { + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + + const { cookie: adminCookie } = await loginAsKibanaAdmin(); + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_delete') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ + objects, + }) + .expect(200); + for (const { id, success } of res.body.statuses) { + const object = objects.find((obj) => obj.id === id); + expect(object).to.not.be(undefined); + expect(success).to.be(true); + } + }); + }); + + describe('failure modes', () => { + it('rejects if all objects are write-restricted and inaccessible', async () => { + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + + await createSimpleUser(['kibana_savedobjects_editor']); + const { cookie: notOwnerCookie } = await loginAsNotObjectOwner( + 'simple_user', + 'changeme' + ); + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_delete') + .set('kbn-xsrf', 'true') + .set('cookie', notOwnerCookie.cookieString()) + .send({ + objects, + }) + .expect(403); + expect(res.body).to.have.property('message'); + expect(res.body.message).to.contain( + `Unable to bulk_delete ${ACCESS_CONTROL_TYPE}, access control restrictions for` + ); + expect(res.body.message).to.contain(`${ACCESS_CONTROL_TYPE}:${objectId1}`); // order is not guaranteed + expect(res.body.message).to.contain(`${ACCESS_CONTROL_TYPE}:${objectId2}`); + }); + + it('returns status if all objects are write-restricted but some objects are owned by the current user', async () => { + await activateSimpleUserProfile(); + const { cookie: object1OwnerCookie, profileUid: obj1OwnerId } = + await loginAsObjectOwner('test_user', 'changeme'); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', object1OwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + expect(firstObject.body).to.have.property('accessControl'); + expect(firstObject.body.accessControl).to.have.property('owner', obj1OwnerId); + expect(firstObject.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + + await createSimpleUser(['kibana_savedobjects_editor']); + const { cookie: object2OwnerCookie, profileUid: obj2OwnerId } = + await loginAsNotObjectOwner('simple_user', 'changeme'); + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', object2OwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + expect(secondObject.body).to.have.property('accessControl'); + expect(secondObject.body.accessControl).to.have.property('owner', obj2OwnerId); + expect(secondObject.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_delete') + .set('kbn-xsrf', 'true') + .set('cookie', object2OwnerCookie.cookieString()) + .send({ + objects, + }) + .expect(200); + + expect(res.body).to.have.property('statuses'); + expect(res.body.statuses).to.be.an('array'); + expect(res.body.statuses).to.have.length(2); + expect(res.body.statuses).to.eql([ + { + id: objectId1, + type: ACCESS_CONTROL_TYPE, + success: false, + error: { + statusCode: 403, + error: 'Forbidden', + message: + 'Deleting objects in "write_restricted" mode that are owned by another user requires the "manage_access_control" privilege.', + }, + }, + { + id: objectId2, + type: ACCESS_CONTROL_TYPE, + success: true, + }, + ]); + }); + + it('returns status if some objects are in default mode', async () => { + await activateSimpleUserProfile(); + const { cookie: objectOwnerCookie, profileUid: obj1OwnerId } = await loginAsObjectOwner( + 'test_user', + 'changeme' + ); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + expect(firstObject.body).to.have.property('accessControl'); + expect(firstObject.body.accessControl).to.have.property('owner', obj1OwnerId); + expect(firstObject.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: false }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + expect(secondObject.body).to.have.property('accessControl'); + expect(secondObject.body.accessControl).to.have.property('owner', obj1OwnerId); + expect(secondObject.body.accessControl).to.have.property('accessMode', 'default'); + + await createSimpleUser(['kibana_savedobjects_editor']); + const { cookie: notOwnerCookie } = await loginAsNotObjectOwner( + 'simple_user', + 'changeme' + ); + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_delete') + .set('kbn-xsrf', 'true') + .set('cookie', notOwnerCookie.cookieString()) + .send({ + objects, + }) + .expect(200); + + expect(res.body).to.have.property('statuses'); + expect(res.body.statuses).to.be.an('array'); + expect(res.body.statuses).to.have.length(2); + expect(res.body.statuses).to.eql([ + { + id: objectId1, + type: ACCESS_CONTROL_TYPE, + success: false, + error: { + statusCode: 403, + error: 'Forbidden', + message: + 'Deleting objects in "write_restricted" mode that are owned by another user requires the "manage_access_control" privilege.', + }, + }, + { + id: objectId2, + type: ACCESS_CONTROL_TYPE, + success: true, + }, + ]); + }); + + it('returns status if some authorized types do not support access control', async () => { + await activateSimpleUserProfile(); + const { cookie: objectOwnerCookie, profileUid: obj1OwnerId } = await loginAsObjectOwner( + 'test_user', + 'changeme' + ); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + expect(firstObject.body).to.have.property('accessControl'); + expect(firstObject.body.accessControl).to.have.property('owner', obj1OwnerId); + expect(firstObject.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: NON_ACCESS_CONTROL_TYPE }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + expect(secondObject.body).not.to.have.property('accessControl'); + + const { cookie: notOwnerCookie } = await loginAsNotObjectOwner( + 'simple_user', + 'changeme' + ); + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_delete') + .set('kbn-xsrf', 'true') + .set('cookie', notOwnerCookie.cookieString()) + .send({ + objects, + }) + .expect(200); + + expect(res.body).to.have.property('statuses'); + expect(res.body.statuses).to.be.an('array'); + expect(res.body.statuses).to.have.length(2); + expect(res.body.statuses).to.eql([ + { + id: objectId1, + type: ACCESS_CONTROL_TYPE, + success: false, + error: { + statusCode: 403, + error: 'Forbidden', + message: + 'Deleting objects in "write_restricted" mode that are owned by another user requires the "manage_access_control" privilege.', + }, + }, + { + id: objectId2, + type: NON_ACCESS_CONTROL_TYPE, + success: true, + }, + ]); + }); + }); + }); + + describe('force bulk delete ownable objects', () => { + it('allow owner to bulk delete objects marked as write-restricted', async () => { + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_delete') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ + objects, + force: true, + }) + .expect(200); + + for (const { id, success } of res.body.statuses) { + const object = objects.find((obj) => obj.id === id); + expect(object).to.not.be(undefined); + expect(success).to.be(true); + } + }); + + it('allow admin to bulk delete objects marked as write-restricted', async () => { + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + + const { cookie: adminCookie } = await loginAsKibanaAdmin(); + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_delete') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ + objects, + force: true, + }) + .expect(200); + for (const { id, success } of res.body.statuses) { + const object = objects.find((obj) => obj.id === id); + expect(object).to.not.be(undefined); + expect(success).to.be(true); + } + }); + it('does not allow non-owner to bulk delete objects marked as write-restricted', async () => { + await activateSimpleUserProfile(); + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + + const { cookie: notOwnerCookie } = await loginAsNotObjectOwner('simple_user', 'changeme'); + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_delete') + .set('kbn-xsrf', 'true') + .set('cookie', notOwnerCookie.cookieString()) + .send({ + objects, + force: true, + }) + .expect(403); + expect(res.body).to.have.property('message'); + expect(res.body.message).to.contain( + `Unable to bulk_delete ${ACCESS_CONTROL_TYPE}, access control restrictions for` + ); + expect(res.body.message).to.contain(`${ACCESS_CONTROL_TYPE}:${objectId1}`); // order is not guaranteed + expect(res.body.message).to.contain(`${ACCESS_CONTROL_TYPE}:${objectId2}`); + }); + }); + }); + + describe('#change_ownership', () => { + it('should transfer ownership of write-restricted objects by owner', async () => { + const { profileUid: simpleUserProfileUid } = await activateSimpleUserProfile(); + + const { cookie: ownerCookie, profileUid } = await loginAsObjectOwner( + 'test_user', + 'changeme' + ); + + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const objectId = createResponse.body.id; + expect(createResponse.body.accessControl).to.have.property('owner', profileUid); + + await supertestWithoutAuth + .put('/access_control_objects/change_owner') + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .send({ + objects: [{ id: objectId, type: ACCESS_CONTROL_TYPE }], + newOwnerProfileUid: simpleUserProfileUid, + }) + .expect(200); + + const getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${objectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .expect(200); + expect(getResponse.body).to.have.property('accessControl'); + expect(getResponse.body.accessControl).to.have.property('owner', simpleUserProfileUid); + }); + + it('should throw when transferring ownership of object owned by a different user and not admin', async () => { + const { profileUid: simpleUserProfileUid } = await activateSimpleUserProfile(); + const { cookie: adminCookie, profileUid: adminProfileUid } = await loginAsKibanaAdmin(); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const objectId = createResponse.body.id; + + expect(createResponse.body.accessControl).to.have.property('owner', adminProfileUid); + + const { cookie: notOwnerCookie } = await loginAsNotObjectOwner('test_user', 'changeme'); + const transferResponse = await supertestWithoutAuth + .put('/access_control_objects/change_owner') + .set('kbn-xsrf', 'true') + .set('cookie', notOwnerCookie.cookieString()) + .send({ + objects: [{ id: objectId, type: ACCESS_CONTROL_TYPE }], + newOwnerProfileUid: simpleUserProfileUid, + }) + .expect(403); + + expect(transferResponse.body).to.have.property('message'); + expect(transferResponse.body.message).to.contain( + `Access denied: Unable to manage access control for ${ACCESS_CONTROL_TYPE}` + ); + }); + + it('should allow admins to transfer ownership of any object', async () => { + const { profileUid: simpleUserProfileUid } = await activateSimpleUserProfile(); + const { cookie: ownerCookie, profileUid } = await loginAsObjectOwner( + 'test_user', + 'changeme' + ); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const objectId = createResponse.body.id; + + expect(createResponse.body.accessControl).to.have.property('owner', profileUid); + + const { cookie: adminCookie } = await loginAsKibanaAdmin(); + await supertestWithoutAuth + .put('/access_control_objects/change_owner') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ + objects: [{ id: objectId, type: ACCESS_CONTROL_TYPE }], + newOwnerProfileUid: simpleUserProfileUid, + }) + .expect(200); + + const getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${objectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .expect(200); + + expect(getResponse.body.accessControl).to.have.property('owner', simpleUserProfileUid); + }); + + it('should allow bulk transfer ownership of allowed objects', async () => { + const { profileUid: simpleUserProfileUid } = await activateSimpleUserProfile(); + const { cookie: ownerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const { cookie: adminCookie } = await loginAsKibanaAdmin(); + const firstCreate = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const firstObjectId = firstCreate.body.id; + + const secondCreate = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const secondObjectId = secondCreate.body.id; + + await supertestWithoutAuth + .put('/access_control_objects/change_owner') + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .send({ + objects: [ + { id: firstObjectId, type: firstCreate.body.type }, + { id: secondObjectId, type: secondCreate.body.type }, + ], + newOwnerProfileUid: simpleUserProfileUid, + }) + .expect(200); + { + const getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${firstObjectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .expect(200); + + expect(getResponse.body.accessControl).to.have.property('owner', simpleUserProfileUid); + } + { + const getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${secondObjectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .expect(200); + expect(getResponse.body.accessControl).to.have.property('owner', simpleUserProfileUid); + } + }); + + it('sets the default mode when setting the ownership of an object without access control metadata', async () => { + const { profileUid: simpleUserProfileUid } = await activateSimpleUserProfile(); + + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set( + 'Authorization', + `Basic ${Buffer.from(`${adminTestUser.username}:${adminTestUser.password}`).toString( + 'base64' + )}` + ) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + const objectId = createResponse.body.id; + + expect(createResponse.body).not.to.have.property('accessControl'); + + const { cookie: adminCookie } = await loginAsKibanaAdmin(); + + let getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${objectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .expect(200); + expect(getResponse.body).not.to.have.property('accessControl'); + + await supertestWithoutAuth + .put('/access_control_objects/change_owner') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ + objects: [{ id: objectId, type: ACCESS_CONTROL_TYPE }], + newOwnerProfileUid: simpleUserProfileUid, + }) + .expect(200); + + getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${objectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .expect(200); + + expect(getResponse.body.accessControl).to.have.property('owner', simpleUserProfileUid); + expect(getResponse.body.accessControl).to.have.property('accessMode', 'default'); + }); + + describe('partial bulk change ownership', () => { + it('should allow bulk transfer ownership of allowed objects', async () => { + const { profileUid: simpleUserProfileUid } = await activateSimpleUserProfile(); + const { cookie: ownerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const firstCreate = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const firstObjectId = firstCreate.body.id; + + const secondCreate = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .send({ type: NON_ACCESS_CONTROL_TYPE }) + .expect(200); + const secondObjectId = secondCreate.body.id; + + const transferResponse = await supertestWithoutAuth + .put('/access_control_objects/change_owner') + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .send({ + objects: [ + { id: firstObjectId, type: firstCreate.body.type }, + { id: secondObjectId, type: secondCreate.body.type }, + ], + newOwnerProfileUid: simpleUserProfileUid, + }) + .expect(200); + expect(transferResponse.body.objects).to.have.length(2); + transferResponse.body.objects.forEach( + (object: { id: string; type: string; error?: any }) => { + if (object.type === ACCESS_CONTROL_TYPE) { + expect(object).to.have.property('id', firstObjectId); + } + if (object.type === NON_ACCESS_CONTROL_TYPE) { + expect(object).to.have.property('id', secondObjectId); + expect(object).to.have.property('error'); + expect(object.error).to.have.property('output'); + expect(object.error.output).to.have.property('payload'); + expect(object.error.output.payload).to.have.property('message'); + expect(object.error.output.payload.message).to.contain( + `The type ${NON_ACCESS_CONTROL_TYPE} does not support access control: Bad Request` + ); + } + } + ); + }); + }); + }); + + describe('#change_access_mode', () => { + it('should allow admins to change access mode of any object', async () => { + const { cookie: ownerCookie, profileUid } = await loginAsObjectOwner( + 'test_user', + 'changeme' + ); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: false }) + .expect(200); + const objectId = createResponse.body.id; + expect(createResponse.body.accessControl).to.have.property('owner', profileUid); + + const { cookie: adminCookie } = await loginAsKibanaAdmin(); + const response = await supertestWithoutAuth + .put('/access_control_objects/change_access_mode') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ + objects: [{ id: objectId, type: ACCESS_CONTROL_TYPE }], + newAccessMode: 'write_restricted', + }) + .expect(200); + expect(response.body.objects).to.have.length(1); + expect(response.body.objects[0].id).to.eql(objectId); + expect(response.body.objects[0].type).to.eql(ACCESS_CONTROL_TYPE); + + const getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${objectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .expect(200); + + expect(getResponse.body.accessControl).to.have.property('accessMode', 'write_restricted'); + }); + + it('allow owner to update object data after access mode change', async () => { + const { cookie: ownerCookie, profileUid } = await loginAsObjectOwner( + 'test_user', + 'changeme' + ); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: false }) + .expect(200); + const objectId = createResponse.body.id; + expect(createResponse.body.accessControl).to.have.property('owner', profileUid); + + const { cookie: adminCookie } = await loginAsKibanaAdmin(); + const response = await supertestWithoutAuth + .put('/access_control_objects/change_access_mode') + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .send({ + objects: [{ id: objectId, type: ACCESS_CONTROL_TYPE }], + newAccessMode: 'write_restricted', + }) + .expect(200); + expect(response.body.objects).to.have.length(1); + expect(response.body.objects[0].id).to.eql(objectId); + expect(response.body.objects[0].type).to.eql(ACCESS_CONTROL_TYPE); + + const getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${objectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .expect(200); + + expect(getResponse.body.accessControl).to.have.property('accessMode', 'write_restricted'); + + const updateResponse = await supertestWithoutAuth + .put('/access_control_objects/update') + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .send({ objectId, type: ACCESS_CONTROL_TYPE }) + .expect(200); + expect(updateResponse.body.id).to.eql(objectId); + expect(updateResponse.body.attributes).to.have.property( + 'description', + 'updated description' + ); + }); + + it('should throw when trying to change access mode on locked objects when not owner', async () => { + const { cookie: ownerCookie, profileUid } = await loginAsObjectOwner( + 'test_user', + 'changeme' + ); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + + const objectId = createResponse.body.id; + expect(createResponse.body.accessControl).to.have.property('owner', profileUid); + + await activateSimpleUserProfile(); + const { cookie: notOwnerCookie } = await loginAsNotObjectOwner('simple_user', 'changeme'); + const updateResponse = await supertestWithoutAuth + .put('/access_control_objects/change_access_mode') + .set('kbn-xsrf', 'true') + .set('cookie', notOwnerCookie.cookieString()) + .send({ + objects: [{ id: objectId, type: ACCESS_CONTROL_TYPE }], + newAccessMode: 'write_restricted', + }) + .expect(403); + expect(updateResponse.body).to.have.property('message'); + expect(updateResponse.body.message).to.contain( + `Access denied: Unable to manage access control for ${ACCESS_CONTROL_TYPE}` + ); + }); + + it('allows updates by non-owner after removing write-restricted access mode', async () => { + const { cookie: ownerCookie, profileUid } = await loginAsObjectOwner( + 'test_user', + 'changeme' + ); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const objectId = createResponse.body.id; + expect(createResponse.body.accessControl).to.have.property('owner', profileUid); + + await supertestWithoutAuth + .put('/access_control_objects/change_access_mode') + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .send({ + objects: [{ id: objectId, type: ACCESS_CONTROL_TYPE }], + newAccessMode: 'default', + }) + .expect(200); + + await createSimpleUser(['kibana_savedobjects_editor']); + const { cookie: notOwnerCookie } = await loginAsNotObjectOwner('simple_user', 'changeme'); + + const updateResponse = await supertestWithoutAuth + .put('/access_control_objects/update') + .set('kbn-xsrf', 'true') + .set('cookie', notOwnerCookie.cookieString()) + .send({ objectId, type: ACCESS_CONTROL_TYPE }) + .expect(200); + expect(updateResponse.body.id).to.eql(objectId); + expect(updateResponse.body.attributes).to.have.property( + 'description', + 'updated description' + ); + }); + + it('sets the current user as the owner when setting the mode of an object without access control metadata', async () => { + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set( + 'Authorization', + `Basic ${Buffer.from(`${adminTestUser.username}:${adminTestUser.password}`).toString( + 'base64' + )}` + ) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + const objectId = createResponse.body.id; + + expect(createResponse.body).not.to.have.property('accessControl'); + + const { cookie: adminCookie, profileUid: adminProfileUid } = await loginAsKibanaAdmin(); + + let getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${objectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .expect(200); + expect(getResponse.body).not.to.have.property('accessControl'); + + await supertestWithoutAuth + .put('/access_control_objects/change_access_mode') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ + objects: [{ id: objectId, type: ACCESS_CONTROL_TYPE }], + newAccessMode: 'write_restricted', + }) + .expect(200); + + getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${objectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .expect(200); + + expect(getResponse.body.accessControl).to.have.property('owner', adminProfileUid); + expect(getResponse.body.accessControl).to.have.property('accessMode', 'write_restricted'); + }); + + describe('partial bulk change access mode', () => { + it('should allow change access mode of allowed objects', async () => { + const { cookie: ownerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const firstCreate = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + const firstObjectId = firstCreate.body.id; + + const secondCreate = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .send({ type: NON_ACCESS_CONTROL_TYPE }) + .expect(200); + const secondObjectId = secondCreate.body.id; + + const respsetModeResponse = await supertestWithoutAuth + .put('/access_control_objects/change_access_mode') + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .send({ + objects: [ + { id: firstObjectId, type: firstCreate.body.type }, + { id: secondObjectId, type: secondCreate.body.type }, + ], + newAccessMode: 'default', + }) + .expect(200); + expect(respsetModeResponse.body.objects).to.have.length(2); + respsetModeResponse.body.objects.forEach( + (object: { id: string; type: string; error?: any }) => { + if (object.type === ACCESS_CONTROL_TYPE) { + expect(object).to.have.property('id', firstObjectId); + } + if (object.type === NON_ACCESS_CONTROL_TYPE) { + expect(object).to.have.property('id', secondObjectId); + expect(object).to.have.property('error'); + expect(object.error).to.have.property('output'); + expect(object.error.output).to.have.property('payload'); + expect(object.error.output.payload).to.have.property('message'); + expect(object.error.output.payload.message).to.contain( + `The type ${NON_ACCESS_CONTROL_TYPE} does not support access control: Bad Request` + ); + } + } + ); + }); + }); + }); + + describe('access control and RBAC', () => { + // ToDo: + // 1. Make a user with RBAC permissions for the ACCESS_CONTROL_TYPE and create some objects in default access mode. + // 2. Revoke access to the ACCESS_CONTROL_TYPE (e.g. remove the Editor role from simple_user) + // 3. Validate that the user cannot overwrite, update, or delete any of the object that they own due to RBAC + }); + }); +} diff --git a/x-pack/platform/test/spaces_api_integration/access_control_objects/apis/spaces/feature_disabled.ts b/x-pack/platform/test/spaces_api_integration/access_control_objects/apis/spaces/feature_disabled.ts new file mode 100644 index 0000000000000..b4a7a8c92933f --- /dev/null +++ b/x-pack/platform/test/spaces_api_integration/access_control_objects/apis/spaces/feature_disabled.ts @@ -0,0 +1,939 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { parse as parseCookie } from 'tough-cookie'; + +import { ACCESS_CONTROL_TYPE } from '@kbn/access-control-test-plugin/server'; +import expect from '@kbn/expect'; +import { adminTestUser } from '@kbn/test'; + +import type { FtrProviderContext } from '../../../../functional/ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const es = getService('es'); + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const security = getService('security'); + + const createSimpleUser = async (roles: string[] = ['viewer']) => { + await es.security.putUser({ + username: 'simple_user', + refresh: 'wait_for', + password: 'changeme', + roles, + }); + }; + + const login = async (username: string, password: string | undefined) => { + const response = await supertestWithoutAuth + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username, password }, + }) + .expect(200); + const cookie = parseCookie(response.headers['set-cookie'][0])!; + const profileUidResponse = await supertestWithoutAuth + .get('/internal/security/me') + .set('Cookie', cookie.cookieString()) + .expect(200); + return { + cookie, + profileUid: profileUidResponse.body.profile_uid, + }; + }; + + const loginAsKibanaAdmin = () => login(adminTestUser.username, adminTestUser.password); + + const loginAsObjectOwner = (username: string, password: string) => login(username, password); + + const loginAsNotObjectOwner = (username: string, password: string) => login(username, password); + + // This test suite relies on the access_control_test_plugin, but the feature flag is explicitly disabled + // in the congig. This means that ACCESS_CONTROL_TYPE is still registered as supporting access control, + // however, the feature is disabled and the type will not support access control in practice. These tests + // aim to validate that the object type still behaves as expected - like any other object that does not + // support access control. + describe('access control saved objects - feature disabled', () => { + before(async () => { + await security.testUser.setRoles(['kibana_savedobjects_editor']); + await createSimpleUser(); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + + describe('#create', () => { + it('rejects creating a write-restricted object', async () => { + const { cookie: adminCookie } = await loginAsKibanaAdmin(); + const response = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(400); + + expect(response.body).to.have.property('error', 'Bad Request'); + expect(response.body.message).to.contain( + `Cannot create a saved object of type ${ACCESS_CONTROL_TYPE} with an access mode because the type does not support access control: Bad Request` + ); + }); + + it('allows creating an object without access control metadata', async () => { + const { cookie: adminCookie } = await loginAsKibanaAdmin(); + const response = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'xxxxx') + .set('cookie', adminCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + expect(response.body).not.to.have.property('accessControl'); + expect(response.body).to.have.property('type'); + const { type } = response.body; + expect(type).to.be(ACCESS_CONTROL_TYPE); + }); + + it('allows creating an object when there is no active user profile', async () => { + const response = await supertest + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + expect(response.body).not.to.have.property('accessControl'); + expect(response.body).to.have.property('type'); + const { type } = response.body; + expect(type).to.be(ACCESS_CONTROL_TYPE); + }); + + // Note: would be better to test this against an object with access control metadata and + // verify that it makes no difference to the outcome + it('allows overwriting an object by the creating user', async () => { + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, description: 'this will change' }) + .expect(200); + + const objectId = createResponse.body.id; + expect(createResponse.body).not.to.have.property('accessControl'); + + let getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${objectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .expect(200); + expect(getResponse.body).not.to.have.property('accessControl'); + expect(getResponse.body.attributes).to.have.property('description', 'this will change'); + + const overwriteResponse = await supertestWithoutAuth + .post('/access_control_objects/create?overwrite=true') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ id: objectId, type: ACCESS_CONTROL_TYPE, description: 'overwritten!' }) + .expect(200); + + expect(overwriteResponse.body).to.have.property('id', objectId); + expect(overwriteResponse.body).not.to.have.property('accessControl'); + + getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${objectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .expect(200); + expect(getResponse.body).not.to.have.property('accessControl'); + expect(getResponse.body.attributes).to.have.property('description', 'overwritten!'); + }); + + it('allows overwriting an object by a different user', async () => { + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, description: 'this will change' }) + .expect(200); + + const objectId = createResponse.body.id; + expect(createResponse.body).not.to.have.property('accessControl'); + + let getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${objectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .expect(200); + expect(getResponse.body).not.to.have.property('accessControl'); + expect(getResponse.body.attributes).to.have.property('description', 'this will change'); + + await createSimpleUser(['kibana_savedobjects_editor']); + const { cookie: notObjectOwnerCookie } = await loginAsNotObjectOwner( + 'simple_user', + 'changeme' + ); + + const overwriteResponse = await supertestWithoutAuth + .post('/access_control_objects/create?overwrite=true') + .set('kbn-xsrf', 'true') + .set('cookie', notObjectOwnerCookie.cookieString()) + .send({ id: objectId, type: ACCESS_CONTROL_TYPE, description: 'overwritten!' }) + .expect(200); + + expect(overwriteResponse.body).to.have.property('id', objectId); + expect(overwriteResponse.body).not.to.have.property('accessControl'); + + getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${objectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', notObjectOwnerCookie.cookieString()) + .expect(200); + expect(getResponse.body).not.to.have.property('accessControl'); + expect(getResponse.body.attributes).to.have.property('description', 'overwritten!'); + }); + }); + + describe('#bulk_create', () => { + it('returns error status when attempting to create write-restricted objects', async () => { + const { cookie: adminCookie } = await loginAsKibanaAdmin(); + + const response = await supertestWithoutAuth + .post('/access_control_objects/bulk_create') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ + objects: [ + { type: ACCESS_CONTROL_TYPE, description: 'valid object' }, + { + type: ACCESS_CONTROL_TYPE, + isWriteRestricted: true, + description: 'invalid object', + }, + ], + }) + .expect(200); + + expect(response.body).to.have.property('saved_objects'); + expect(Array.isArray(response.body.saved_objects)).to.be(true); + expect(response.body.saved_objects).to.have.length(2); + expect(response.body.saved_objects[0].attributes).to.have.property( + 'description', + 'valid object' + ); + expect(response.body.saved_objects[1]).to.have.property('error'); + expect(response.body.saved_objects[1].error).to.have.property( + 'message', + `Cannot create a saved object of type ${ACCESS_CONTROL_TYPE} with an access mode because the type does not support access control: Bad Request` + ); + expect(response.body.saved_objects[1].error).to.have.property('statusCode', 400); + expect(response.body.saved_objects[1].error).to.have.property('error', 'Bad Request'); + }); + + it('allows creating objects without access control metadata', async () => { + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + + const bulkCreateResponse = await supertestWithoutAuth + .post('/access_control_objects/bulk_create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ + objects: [{ type: ACCESS_CONTROL_TYPE }, { type: ACCESS_CONTROL_TYPE }], + }); + expect(bulkCreateResponse.body.saved_objects).to.have.length(2); + for (const obj of bulkCreateResponse.body.saved_objects) { + expect(obj).not.to.have.property('accessControl'); + const getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${obj.id}`) + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .expect(200); + expect(getResponse.body).not.to.have.property('accessControl'); + } + }); + + it('allows creating objects when there is no active user profile', async () => { + const bulkCreateResponse = await supertest + .post('/access_control_objects/bulk_create') + .set('kbn-xsrf', 'true') + .send({ + objects: [{ type: ACCESS_CONTROL_TYPE }, { type: ACCESS_CONTROL_TYPE }], + }); + expect(bulkCreateResponse.body.saved_objects).to.have.length(2); + for (const obj of bulkCreateResponse.body.saved_objects) { + expect(obj).not.to.have.property('accessControl'); + const getResponse = await supertest + .get(`/access_control_objects/${obj.id}`) + .set('kbn-xsrf', 'true') + .expect(200); + expect(getResponse.body).not.to.have.property('accessControl'); + expect(getResponse.body.createdBy).to.be(undefined); + } + }); + + // Note: would be better to test this against an object with access control metadata and + // verify that it makes no difference to the outcome + it('allows overwriting an objects by the creating user', async () => { + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, description: 'this will change' }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, description: 'this will also change' }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + + const objects = [ + { + id: objectId1, + type: type1, + description: 'overwritten!', + }, + { + id: objectId2, + type: type2, + description: 'overwritten!', + }, + ]; + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_create?overwrite=true') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ + objects, + }) + .expect(200); + for (const { id } of res.body.saved_objects) { + const object = objects.find((obj) => obj.id === id); + expect(object).to.not.be(undefined); + expect(object).not.to.have.property('accessControl'); + + const getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${id}`) + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .expect(200); + expect(getResponse.body).not.to.have.property('accessControl'); + expect(getResponse.body.attributes).to.have.property('description', 'overwritten!'); + } + }); + + it('allows overwriting an objects by a different user', async () => { + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, description: 'this will change' }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, description: 'this will also change' }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + + const objects = [ + { + id: objectId1, + type: type1, + description: 'overwritten!', + }, + { + id: objectId2, + type: type2, + description: 'overwritten!', + }, + ]; + + await createSimpleUser(['kibana_savedobjects_editor']); + const { cookie: notObjectOwnerCookie } = await loginAsNotObjectOwner( + 'simple_user', + 'changeme' + ); + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_create?overwrite=true') + .set('kbn-xsrf', 'true') + .set('cookie', notObjectOwnerCookie.cookieString()) + .send({ + objects, + }) + .expect(200); + for (const { id } of res.body.saved_objects) { + const object = objects.find((obj) => obj.id === id); + expect(object).to.not.be(undefined); + expect(object).not.to.have.property('accessControl'); + + const getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${id}`) + .set('kbn-xsrf', 'true') + .set('cookie', notObjectOwnerCookie.cookieString()) + .expect(200); + expect(getResponse.body).not.to.have.property('accessControl'); + expect(getResponse.body.attributes).to.have.property('description', 'overwritten!'); + } + }); + }); + + describe('#update', () => { + it('allows update of a write-restricted object by the creating user', async () => { + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + + const objectId = createResponse.body.id; + expect(createResponse.body.attributes).to.have.property('description', 'test'); + expect(createResponse.body).not.to.have.property('accessControl'); + + const updateResponse = await supertestWithoutAuth + .put('/access_control_objects/update') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ objectId, type: ACCESS_CONTROL_TYPE }) + .expect(200); + + expect(updateResponse.body.id).to.eql(objectId); + expect(updateResponse.body.attributes).to.have.property( + 'description', + 'updated description' + ); + + const getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${objectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .expect(200); + expect(getResponse.body).not.to.have.property('accessControl'); + expect(getResponse.body.attributes).to.have.property('description', 'updated description'); + }); + + it('allows update of a write-restricted object by a different user', async () => { + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + + const objectId = createResponse.body.id; + expect(createResponse.body.attributes).to.have.property('description', 'test'); + expect(createResponse.body).not.to.have.property('accessControl'); + + await createSimpleUser(['kibana_savedobjects_editor']); + const { cookie: notObjectOwnerCookie } = await loginAsNotObjectOwner( + 'simple_user', + 'changeme' + ); + + const updateResponse = await supertestWithoutAuth + .put('/access_control_objects/update') + .set('kbn-xsrf', 'true') + .set('cookie', notObjectOwnerCookie.cookieString()) + .send({ objectId, type: ACCESS_CONTROL_TYPE }) + .expect(200); + + expect(updateResponse.body.id).to.eql(objectId); + expect(updateResponse.body.attributes).to.have.property( + 'description', + 'updated description' + ); + + const getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${objectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', notObjectOwnerCookie.cookieString()) + .expect(200); + expect(getResponse.body).not.to.have.property('accessControl'); + expect(getResponse.body.attributes).to.have.property('description', 'updated description'); + }); + + it('rejects update of a write-restricted object by a user withouth RBAC permissions', async () => { + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + + const objectId = createResponse.body.id; + expect(createResponse.body.attributes).to.have.property('description', 'test'); + expect(createResponse.body).not.to.have.property('accessControl'); + + await createSimpleUser(['viewer']); + const { cookie: notObjectOwnerCookie } = await loginAsNotObjectOwner( + 'simple_user', + 'changeme' + ); + + await supertestWithoutAuth + .put('/access_control_objects/update') + .set('kbn-xsrf', 'true') + .set('cookie', notObjectOwnerCookie.cookieString()) + .send({ objectId, type: ACCESS_CONTROL_TYPE }) + .expect(403); + }); + }); + + describe('#bulk_update', () => { + it('allows bulk update of a write-restricted object by the creating user', async () => { + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_update') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ + objects, + }) + .expect(200); + for (const { id, attributes } of res.body.saved_objects) { + const object = objects.find((obj) => obj.id === id); + expect(object).to.not.be(undefined); + expect(attributes).to.have.property('description', 'updated description'); + } + }); + + it('allows bulk update of a write-restricted object by different user', async () => { + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + await createSimpleUser(['kibana_savedobjects_editor']); + const { cookie: notObjectOwnerCookie } = await loginAsNotObjectOwner( + 'simple_user', + 'changeme' + ); + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_update') + .set('kbn-xsrf', 'true') + .set('cookie', notObjectOwnerCookie.cookieString()) + .send({ + objects, + }) + .expect(200); + for (const { id, attributes } of res.body.saved_objects) { + const object = objects.find((obj) => obj.id === id); + expect(object).to.not.be(undefined); + expect(attributes).to.have.property('description', 'updated description'); + } + }); + + it('rejects bulk update of a write-restricted object by a user withouth RBAC permissions', async () => { + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + await createSimpleUser(['viewer']); + const { cookie: notObjectOwnerCookie } = await loginAsNotObjectOwner( + 'simple_user', + 'changeme' + ); + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_update') + .set('kbn-xsrf', 'true') + .set('cookie', notObjectOwnerCookie.cookieString()) + .send({ + objects, + }) + .expect(403); + expect(res.body).to.have.property('message'); + expect(res.body.message).to.contain(`Unable to bulk_update ${ACCESS_CONTROL_TYPE}`); + }); + }); + + describe('#delete', () => { + it('allow creating user to delete object', async () => { + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + const objectId = createResponse.body.id; + + await supertestWithoutAuth + .delete(`/access_control_objects/${objectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .expect(200); + + const getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${objectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .expect(404); + expect(getResponse.body).to.have.property('message'); + expect(getResponse.body.message).to.contain( + `Saved object [${ACCESS_CONTROL_TYPE}/${objectId}] not found` + ); + }); + + it('allows non-creating user to delete object with permissions', async () => { + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + const objectId = createResponse.body.id; + + await createSimpleUser(['kibana_savedobjects_editor']); + const { cookie: notObjectOwnerCookie } = await loginAsNotObjectOwner( + 'simple_user', + 'changeme' + ); + await supertestWithoutAuth + .delete(`/access_control_objects/${objectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', notObjectOwnerCookie.cookieString()) + .expect(200); + + const getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${objectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .expect(404); + expect(getResponse.body).to.have.property('message'); + expect(getResponse.body.message).to.contain( + `Saved object [${ACCESS_CONTROL_TYPE}/${objectId}] not found` + ); + }); + + it('rejects deletion of a write-restricted object by a user withouth RBAC permissions', async () => { + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + const objectId = createResponse.body.id; + + await createSimpleUser(['viewer']); + const { cookie: notObjectOwnerCookie } = await loginAsNotObjectOwner( + 'simple_user', + 'changeme' + ); + const res = await supertestWithoutAuth + .delete(`/access_control_objects/${objectId}`) + .set('kbn-xsrf', 'true') + .set('cookie', notObjectOwnerCookie.cookieString()) + .expect(403); + expect(res.body).to.have.property('message'); + expect(res.body.message).to.contain(`Unable to delete ${ACCESS_CONTROL_TYPE}`); + }); + }); + + describe('#bulk_delete', () => { + it('allows bulk delete of objects by the creating user', async () => { + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_delete') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ + objects, + }) + .expect(200); + + for (const { id, success } of res.body.statuses) { + const object = objects.find((obj) => obj.id === id); + expect(object).to.not.be(undefined); + expect(success).to.be(true); + } + }); + + it('allows bulk delete of objects by different user', async () => { + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + await createSimpleUser(['kibana_savedobjects_editor']); + const { cookie: notObjectOwnerCookie } = await loginAsNotObjectOwner( + 'simple_user', + 'changeme' + ); + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_delete') + .set('kbn-xsrf', 'true') + .set('cookie', notObjectOwnerCookie.cookieString()) + .send({ + objects, + }) + .expect(200); + + for (const { id, success } of res.body.statuses) { + const object = objects.find((obj) => obj.id === id); + expect(object).to.not.be(undefined); + expect(success).to.be(true); + } + }); + + it('rejects bulk delete of objects by a user withouth RBAC permissions', async () => { + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const firstObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + const { id: objectId1, type: type1 } = firstObject.body; + + const secondObject = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + const { id: objectId2, type: type2 } = secondObject.body; + + const objects = [ + { + id: objectId1, + type: type1, + }, + { + id: objectId2, + type: type2, + }, + ]; + + await createSimpleUser(['viewer']); + const { cookie: notObjectOwnerCookie } = await loginAsNotObjectOwner( + 'simple_user', + 'changeme' + ); + + const res = await supertestWithoutAuth + .post('/access_control_objects/bulk_delete') + .set('kbn-xsrf', 'true') + .set('cookie', notObjectOwnerCookie.cookieString()) + .send({ + objects, + }) + .expect(403); + expect(res.body).to.have.property('message'); + expect(res.body.message).to.contain(`Unable to bulk_delete ${ACCESS_CONTROL_TYPE}`); + }); + }); + + describe('#change_owner', () => { + it('throws when trying to update ownership of ownable type', async () => { + const { cookie: ownerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + const objectId = createResponse.body.id; + + const changeOwnershipResponse = await supertestWithoutAuth + .put('/access_control_objects/change_owner') + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .send({ + objects: [{ id: objectId, type: ACCESS_CONTROL_TYPE }], + newOwnerProfileUid: 'u_nonexistinguser_ver', + }) + .expect(200); + expect(changeOwnershipResponse.body.objects).to.have.length(1); + const objectToAssert = changeOwnershipResponse.body.objects[0]; + expect(objectToAssert.id).to.eql(objectId); + expect(objectToAssert.type).to.eql(ACCESS_CONTROL_TYPE); + expect(objectToAssert).to.have.property('error'); + expect(objectToAssert.error.output).to.have.property('payload'); + expect(objectToAssert.error.output.payload).to.have.property('message'); + expect(objectToAssert.error.output.payload.message).to.contain( + `The type ${ACCESS_CONTROL_TYPE} does not support access control: Bad Request` + ); + }); + }); + + describe('#change_access_mode', () => { + it('throws when trying to update access mode of ownable type', async () => { + const { cookie: ownerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE }) + .expect(200); + const objectId = createResponse.body.id; + + const changeAccessModeResponse = await supertestWithoutAuth + .put('/access_control_objects/change_access_mode') + .set('kbn-xsrf', 'true') + .set('cookie', ownerCookie.cookieString()) + .send({ + objects: [{ id: objectId, type: ACCESS_CONTROL_TYPE }], + newAccessMode: 'write_restricted', + }) + .expect(200); + expect(changeAccessModeResponse.body.objects).to.have.length(1); + const objectToAssert = changeAccessModeResponse.body.objects[0]; + expect(objectToAssert.id).to.eql(objectId); + expect(objectToAssert.type).to.eql(ACCESS_CONTROL_TYPE); + expect(objectToAssert).to.have.property('error'); + expect(objectToAssert.error.output).to.have.property('payload'); + expect(objectToAssert.error.output.payload).to.have.property('message'); + expect(objectToAssert.error.output.payload.message).to.contain( + `The type ${ACCESS_CONTROL_TYPE} does not support access control: Bad Request` + ); + }); + }); + }); +} diff --git a/x-pack/platform/test/spaces_api_integration/access_control_objects/apis/spaces/import_export.ts b/x-pack/platform/test/spaces_api_integration/access_control_objects/apis/spaces/import_export.ts new file mode 100644 index 0000000000000..49d9742fd4615 --- /dev/null +++ b/x-pack/platform/test/spaces_api_integration/access_control_objects/apis/spaces/import_export.ts @@ -0,0 +1,1397 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { parse as parseCookie } from 'tough-cookie'; + +import { + ACCESS_CONTROL_TYPE, + NON_ACCESS_CONTROL_TYPE, +} from '@kbn/access-control-test-plugin/server'; +import type { SavedObjectsImportRetry } from '@kbn/core/public'; +import expect from '@kbn/expect'; +import { adminTestUser } from '@kbn/test'; + +import type { FtrProviderContext } from '../../../../functional/ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const security = getService('security'); + + const login = async (username: string, password: string | undefined) => { + const response = await supertestWithoutAuth + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username, password }, + }) + .expect(200); + const cookie = parseCookie(response.headers['set-cookie'][0])!; + const profileUidResponse = await supertestWithoutAuth + .get('/internal/security/me') + .set('Cookie', cookie.cookieString()) + .expect(200); + return { + cookie, + profileUid: profileUidResponse.body.profile_uid, + }; + }; + + const loginAsKibanaAdmin = () => login(adminTestUser.username, adminTestUser.password); + const loginAsObjectOwner = (username: string, password: string) => login(username, password); + const loginAsNotObjectOwner = (username: string, password: string) => login(username, password); + + const performImport = async ( + toImport: {}[], + userCookie: string, + overwrite: boolean = false, + createNewCopies: boolean = true, + expectStatus: number = 200 + ) => { + const requestBody = toImport.map((obj) => JSON.stringify({ ...obj })).join('\n'); + const query = overwrite ? '?overwrite=true' : createNewCopies ? '?createNewCopies=true' : ''; + const response = await supertestWithoutAuth + .post(`/api/saved_objects/_import${query}`) + .set('kbn-xsrf', 'true') + .set('cookie', userCookie) + .attach('file', Buffer.from(requestBody, 'utf8'), 'export.ndjson') + .expect(expectStatus); + + return response; + }; + + const performResolveImportErrors = async ( + toImport: {}[], + userCookie: string, + retries: SavedObjectsImportRetry[] = [], + createNewCopies: boolean = true, + expectStatus: number = 200 + ) => { + const requestBody = toImport.map((obj) => JSON.stringify({ ...obj })).join('\n'); + const query = createNewCopies ? '?createNewCopies=true' : ''; + const response = await supertestWithoutAuth + .post(`/api/saved_objects/_resolve_import_errors${query}`) + .set('kbn-xsrf', 'true') + .set('cookie', userCookie) + .field('retries', JSON.stringify(retries)) + .attach('file', Buffer.from(requestBody, 'utf8'), 'export.ndjson') + .expect(expectStatus); + + return response; + }; + + describe('read only saved objects', () => { + before(async () => { + await security.testUser.setRoles(['kibana_savedobjects_editor']); + }); + + after(async () => { + await security.testUser.restoreDefaults(); + }); + + describe('#import', () => { + it('should reject import of objects with unexpected access control metadata (unsupported types)', async () => { + const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + + const toImport = [ + { + accessControl: { accessMode: 'default', owner: 'just_some_dude' }, // UNEXPECTED ACCESS CONTROL META + attributes: { description: 'test' }, + coreMigrationVersion: '8.8.0', + created_at: '2025-07-16T10:03:03.253Z', + created_by: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + id: '11111111111111111111111111111111', + managed: false, + references: [], + type: NON_ACCESS_CONTROL_TYPE, + updated_at: '2025-07-16T10:03:03.253Z', + updated_by: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + version: 'WzY5LDFd', + }, + { + excludedObjects: [], + excludedObjectsCount: 0, + exportedCount: 1, + missingRefCount: 0, + missingReferences: [], + }, + ]; + + const response = await performImport(toImport, objectOwnerCookie.cookieString()); + + expect(response.body).not.to.have.property('successResults'); + const errors = response.body.errors; + expect(Array.isArray(errors)).to.be(true); + expect(errors.length).to.be(1); + expect(errors[0]).to.have.property('error'); + expect(errors[0].error).to.have.property('type', 'unexpected_access_control_metadata'); + }); + + describe('creating new objects', () => { + it(`should apply the current user as owner, and 'default' access mode, only to supported object types`, async () => { + const { cookie: objectOwnerCookie, profileUid: testProfileId } = await loginAsObjectOwner( + 'test_user', + 'changeme' + ); + + const toImport = [ + { + // some data in the file that defines a specific user and mode + accessControl: { accessMode: 'write_restricted', owner: 'some_user' }, + attributes: { description: 'test' }, + coreMigrationVersion: '8.8.0', + created_at: '2025-07-16T10:03:03.253Z', + created_by: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + id: '11111111111111111111111111111111', + managed: false, + references: [], + type: ACCESS_CONTROL_TYPE, + updated_at: '2025-07-16T10:03:03.253Z', + updated_by: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + version: 'WzY5LDFd', + }, + { + // some data in the file for an type that does not support access control + attributes: { description: 'test' }, + coreMigrationVersion: '8.8.0', + created_at: '2025-07-16T10:03:03.253Z', + created_by: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + id: '22222222222222222222222222222222', + managed: false, + references: [], + type: NON_ACCESS_CONTROL_TYPE, + updated_at: '2025-07-16T10:03:03.253Z', + updated_by: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + version: 'WzY5LDFd', + }, + { + excludedObjects: [], + excludedObjectsCount: 0, + exportedCount: 1, + missingRefCount: 0, + missingReferences: [], + }, + ]; + + const response = await performImport(toImport, objectOwnerCookie.cookieString()); + + const results = response.body.successResults; + expect(Array.isArray(results)).to.be(true); + expect(results.length).to.be(2); + expect(results[0].type).to.be(ACCESS_CONTROL_TYPE); + expect(results[1].type).to.be(NON_ACCESS_CONTROL_TYPE); + + let getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${results[0].destinationId}`) + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .expect(200); + + expect(getResponse.body).to.have.property('accessControl'); + expect(getResponse.body.accessControl).to.have.property('accessMode', 'default'); + expect(getResponse.body.accessControl).to.have.property('owner', testProfileId); + + getResponse = await supertestWithoutAuth + .get(`/non_access_control_objects/${results[1].destinationId}`) + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .expect(200); + expect(getResponse.body).not.to.have.property('accessControl'); + }); + + it(`should create objects supporting access control without access control metadata if there is not profile ID`, async () => { + const toImport = [ + { + // some data in the file that defines a specific user and mode + accessControl: { accessMode: 'write_restricted', owner: 'some_user' }, + attributes: { description: 'test' }, + coreMigrationVersion: '8.8.0', + created_at: '2025-07-16T10:03:03.253Z', + created_by: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + id: '11111111111111111111111111111111', + managed: false, + references: [], + type: ACCESS_CONTROL_TYPE, + updated_at: '2025-07-16T10:03:03.253Z', + updated_by: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + version: 'WzY5LDFd', + }, + { + // some data in the file for an type that does not support access control + attributes: { description: 'test' }, + coreMigrationVersion: '8.8.0', + created_at: '2025-07-16T10:03:03.253Z', + created_by: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + id: '22222222222222222222222222222222', + managed: false, + references: [], + type: NON_ACCESS_CONTROL_TYPE, + updated_at: '2025-07-16T10:03:03.253Z', + updated_by: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + version: 'WzY5LDFd', + }, + { + excludedObjects: [], + excludedObjectsCount: 0, + exportedCount: 1, + missingRefCount: 0, + missingReferences: [], + }, + ]; + + const requestBody = toImport.map((obj) => JSON.stringify({ ...obj })).join('\n'); + const response = await supertestWithoutAuth + .post(`/api/saved_objects/_import?createNewCopies=true`) + .set('kbn-xsrf', 'true') + .set( + 'Authorization', + `Basic ${Buffer.from(`${adminTestUser.username}:${adminTestUser.password}`).toString( + 'base64' + )}` + ) + .attach('file', Buffer.from(requestBody, 'utf8'), 'export.ndjson') + .expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('successCount', 2); + expect(response.body).to.have.property('successResults'); + expect(Array.isArray(response.body.successResults)).to.be(true); + const results = response.body.successResults; + expect(results.length).to.be(2); + expect(results[0]).to.have.property('type', ACCESS_CONTROL_TYPE); + expect(results[0]).to.have.property('destinationId'); + expect(results[1]).to.have.property('type', NON_ACCESS_CONTROL_TYPE); + expect(results[1]).to.have.property('destinationId'); + + const importedId1 = results[0].destinationId; + const importedId2 = results[1].destinationId; + + let getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${importedId1}`) + .set('kbn-xsrf', 'true') + .set( + 'Authorization', + `Basic ${Buffer.from(`${adminTestUser.username}:${adminTestUser.password}`).toString( + 'base64' + )}` + ) + .expect(200); + expect(getResponse.body).not.to.have.property('accessControl'); + + getResponse = await supertestWithoutAuth + .get(`/non_access_control_objects/${importedId2}`) + .set('kbn-xsrf', 'true') + .set( + 'Authorization', + `Basic ${Buffer.from(`${adminTestUser.username}:${adminTestUser.password}`).toString( + 'base64' + )}` + ) + .expect(200); + expect(getResponse.body).not.to.have.property('accessControl'); + }); + + it('should apply defaults to objects with no access control metadata', async () => { + const { cookie: objectOwnerCookie, profileUid: testProfileId } = await loginAsObjectOwner( + 'test_user', + 'changeme' + ); + + const toImport = [ + { + attributes: { description: 'test' }, + coreMigrationVersion: '8.8.0', + created_at: '2025-07-16T10:03:03.253Z', + created_by: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + id: '11111111111111111111111111111111', + managed: false, + references: [], + type: ACCESS_CONTROL_TYPE, + updated_at: '2025-07-16T10:03:03.253Z', + updated_by: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + version: 'WzY5LDFd', + }, + { + attributes: { description: 'test' }, + coreMigrationVersion: '8.8.0', + created_at: '2025-07-16T10:03:03.253Z', + created_by: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + id: '22222222222222222222222222222222', + managed: false, + references: [], + type: NON_ACCESS_CONTROL_TYPE, + updated_at: '2025-07-16T10:03:03.253Z', + updated_by: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + version: 'WzY5LDFd', + }, + { + excludedObjects: [], + excludedObjectsCount: 0, + exportedCount: 2, + missingRefCount: 0, + missingReferences: [], + }, + ]; + + const response = await performImport(toImport, objectOwnerCookie.cookieString()); + + const results = response.body.successResults; + expect(Array.isArray(results)).to.be(true); + expect(results.length).to.be(2); + expect(results[0].type).to.be(ACCESS_CONTROL_TYPE); + expect(results[1].type).to.be(NON_ACCESS_CONTROL_TYPE); + + let getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${results[0].destinationId}`) + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .expect(200); + + expect(getResponse.body).to.have.property('accessControl'); + expect(getResponse.body.accessControl).to.have.property('accessMode', 'default'); + expect(getResponse.body.accessControl).to.have.property('owner', testProfileId); + + getResponse = await supertestWithoutAuth + .get(`/non_access_control_objects/${results[1].destinationId}`) + .set('kbn-xsrf', 'true') + .set('cookie', objectOwnerCookie.cookieString()) + .expect(200); + expect(getResponse.body).not.to.have.property('accessControl'); + }); + }); + + describe('ovewriting objects', () => { + it('should disallow overwrite of owned objects if not owned by the current user', async () => { + const { cookie: adminCookie, profileUid: adminProfileId } = await loginAsKibanaAdmin(); + + let createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + expect(createResponse.body.type).to.eql(ACCESS_CONTROL_TYPE); + expect(createResponse.body).to.have.property('accessControl'); + expect(createResponse.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + expect(createResponse.body.accessControl).to.have.property('owner', adminProfileId); + const adminObjId = createResponse.body.id; + + const { cookie: testUserCookie, profileUid: testProfileId } = await loginAsNotObjectOwner( + 'test_user', + 'changeme' + ); + + createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', testUserCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + expect(createResponse.body.type).to.eql(ACCESS_CONTROL_TYPE); + expect(createResponse.body).to.have.property('accessControl'); + expect(createResponse.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + expect(createResponse.body.accessControl).to.have.property('owner', testProfileId); + const testUserObjId = createResponse.body.id; + + const toImport = [ + { + // this first object will import ok + accessControl: { accessMode: 'write_restricted', owner: testProfileId }, + attributes: { description: 'test' }, + coreMigrationVersion: '8.8.0', + created_at: '2025-07-16T10:03:03.253Z', + created_by: testProfileId, + id: testUserObjId, + managed: false, + references: [], + type: ACCESS_CONTROL_TYPE, + updated_at: '2025-07-16T10:03:03.253Z', + updated_by: testProfileId, + version: 'WzY5LDFd', + }, + { + // this second object will be rejected because it is owned by another user + accessControl: { accessMode: 'write_restricted', owner: adminProfileId }, + attributes: { description: 'test' }, + coreMigrationVersion: '8.8.0', + created_at: '2025-07-16T10:03:03.253Z', + created_by: adminProfileId, + id: adminObjId, + managed: false, + references: [], + type: ACCESS_CONTROL_TYPE, + updated_at: '2025-07-16T10:03:03.253Z', + updated_by: adminProfileId, + version: 'WzY5LDFd', + }, + { + excludedObjects: [], + excludedObjectsCount: 0, + exportedCount: 2, + missingRefCount: 0, + missingReferences: [], + }, + ]; + + const importResponse = await performImport( + toImport, + testUserCookie.cookieString(), + true, // overwrite = true, + false, // createNewCopies = false + 200 + ); + const results = importResponse.text.split('\n').map((str: string) => JSON.parse(str)); + expect(Array.isArray(results)).to.be(true); + expect(results.length).to.be(1); + const result = results[0]; + expect(result).to.have.property('successCount', 1); + expect(result).to.have.property('success', false); + expect(result).to.have.property('successResults'); + expect(result.successResults).to.eql([ + { + type: ACCESS_CONTROL_TYPE, + id: testUserObjId, + meta: {}, + managed: false, + overwrite: true, + }, + ]); + expect(result).to.have.property('errors'); + expect(result.errors).to.eql([ + { + id: adminObjId, + type: ACCESS_CONTROL_TYPE, + meta: {}, + error: { + message: + 'Overwriting objects in "write_restricted" mode that are owned by another user requires the "manage_access_control" privilege.', + statusCode: 403, + error: 'Forbidden', + type: 'unknown', + }, + overwrite: true, + }, + ]); + }); + + it('should throw if the import only contains objects are not overwritable by the current user', async () => { + const { cookie: adminCookie, profileUid: adminProfileId } = await loginAsKibanaAdmin(); + + let createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + expect(createResponse.body.type).to.eql(ACCESS_CONTROL_TYPE); + expect(createResponse.body).to.have.property('accessControl'); + expect(createResponse.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + expect(createResponse.body.accessControl).to.have.property('owner', adminProfileId); + const firstObjId = createResponse.body.id; + + createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + expect(createResponse.body.type).to.eql(ACCESS_CONTROL_TYPE); + expect(createResponse.body).to.have.property('accessControl'); + expect(createResponse.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + expect(createResponse.body.accessControl).to.have.property('owner', adminProfileId); + const secondObjId = createResponse.body.id; + + const { cookie: testUserCookie } = await loginAsNotObjectOwner('test_user', 'changeme'); + + const toImport = [ + { + // this first object will be rejected because it is owned by another user + accessControl: { accessMode: 'write_restricted', owner: adminProfileId }, + attributes: { description: 'test' }, + coreMigrationVersion: '8.8.0', + created_at: '2025-07-16T10:03:03.253Z', + created_by: adminProfileId, + id: firstObjId, + managed: false, + references: [], + type: ACCESS_CONTROL_TYPE, + updated_at: '2025-07-16T10:03:03.253Z', + updated_by: adminProfileId, + version: 'WzY5LDFd', + }, + { + // this second object will be rejected because it is owned by another user + accessControl: { accessMode: 'write_restricted', owner: adminProfileId }, + attributes: { description: 'test' }, + coreMigrationVersion: '8.8.0', + created_at: '2025-07-16T10:03:03.253Z', + created_by: adminProfileId, + id: secondObjId, + managed: false, + references: [], + type: ACCESS_CONTROL_TYPE, + updated_at: '2025-07-16T10:03:03.253Z', + updated_by: adminProfileId, + version: 'WzY5LDFd', + // there are no other imported object, so the import with throw rather than succeed with partial success + }, + { + excludedObjects: [], + excludedObjectsCount: 0, + exportedCount: 2, + missingRefCount: 0, + missingReferences: [], + }, + ]; + + const importResponse = await performImport( + toImport, + testUserCookie.cookieString(), + true, // overwrite = true, + false, // createNewCopies = false + 403 // entire import fails + ); + const results = importResponse.text.split('\n').map((str: string) => JSON.parse(str)); + expect(Array.isArray(results)).to.be(true); + expect(results.length).to.be(1); + expect(results[0]).to.have.property('statusCode', 403); + expect(results[0]).to.have.property('error', 'Forbidden'); + expect(results[0]).to.have.property('message'); + expect(results[0].message).to.contain( + `Unable to bulk_create ${ACCESS_CONTROL_TYPE}, access control restrictions for ${ACCESS_CONTROL_TYPE}:` + ); + expect(results[0].message).to.contain(`${ACCESS_CONTROL_TYPE}:${firstObjId}`); // the order may vary + expect(results[0].message).to.contain(`${ACCESS_CONTROL_TYPE}:${secondObjId}`); + }); + + it('should allow overwrite of owned objects, but maintain original access control metadata, if owned by the current user', async () => { + const { cookie: testUserCookie, profileUid: testProfileId } = await loginAsObjectOwner( + 'test_user', + 'changeme' + ); + + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', testUserCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + expect(createResponse.body.type).to.eql(ACCESS_CONTROL_TYPE); + expect(createResponse.body.attributes).to.have.property('description', 'test'); + expect(createResponse.body).to.have.property('accessControl'); + expect(createResponse.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + expect(createResponse.body.accessControl).to.have.property('owner', testProfileId); + + const toImport = [ + { + accessControl: { accessMode: 'default', owner: 'some_user' }, + attributes: { description: 'overwritten' }, + coreMigrationVersion: '8.8.0', + created_at: '2025-07-16T10:03:03.253Z', + created_by: testProfileId, + id: createResponse.body.id, + managed: false, + references: [], + type: ACCESS_CONTROL_TYPE, + updated_at: '2025-07-16T10:03:03.253Z', + updated_by: testProfileId, + version: 'WzY5LDFd', + }, + { + excludedObjects: [], + excludedObjectsCount: 0, + exportedCount: 1, + missingRefCount: 0, + missingReferences: [], + }, + ]; + + const importResponse = await performImport( + toImport, + testUserCookie.cookieString(), + true, // overwrite = true, + false // createNewCopies = false + ); + + const results = importResponse.body.successResults; + expect(Array.isArray(results)).to.be(true); + expect(results.length).to.be(1); + expect(results[0].type).to.be(ACCESS_CONTROL_TYPE); + expect(results[0].overwrite).to.be(true); + + const getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${results[0].id}`) + .set('kbn-xsrf', 'true') + .set('cookie', testUserCookie.cookieString()) + .expect(200); + + expect(getResponse.body.attributes).to.have.property('description', 'overwritten'); + expect(getResponse.body).to.have.property('accessControl'); + expect(getResponse.body.accessControl).to.have.property('accessMode', 'write_restricted'); + expect(getResponse.body.accessControl).to.have.property('owner', testProfileId); + }); + + it('should allow overwrite of owned objects, but maintain original access control metadata, if admin', async () => { + const loginResponse = await loginAsObjectOwner('test_user', 'changeme'); + const { cookie: testUserCookie, profileUid: testProfileId } = loginResponse; + + const createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', testUserCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + expect(createResponse.body.type).to.eql(ACCESS_CONTROL_TYPE); + expect(createResponse.body.attributes).to.have.property('description', 'test'); + expect(createResponse.body).to.have.property('accessControl'); + expect(createResponse.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + expect(createResponse.body.accessControl).to.have.property('owner', testProfileId); + + const { cookie: adminCookie, profileUid: adminProfileId } = await loginAsKibanaAdmin(); + + expect(adminProfileId).to.not.eql(testProfileId); + + const toImport = [ + { + accessControl: { accessMode: 'default', owner: 'some_user' }, + attributes: { description: 'overwritten' }, + coreMigrationVersion: '8.8.0', + created_at: '2025-07-16T10:03:03.253Z', + created_by: testProfileId, + id: createResponse.body.id, + managed: false, + references: [], + type: ACCESS_CONTROL_TYPE, + updated_at: '2025-07-16T10:03:03.253Z', + updated_by: testProfileId, + version: 'WzY5LDFd', + }, + { + excludedObjects: [], + excludedObjectsCount: 0, + exportedCount: 1, + missingRefCount: 0, + missingReferences: [], + }, + ]; + + const importResponse = await performImport( + toImport, + adminCookie.cookieString(), + true, // overwrite = true, + false // createNewCopies = false + ); + + const results = importResponse.body.successResults; + expect(Array.isArray(results)).to.be(true); + expect(results.length).to.be(1); + expect(results[0].type).to.be(ACCESS_CONTROL_TYPE); + expect(results[0].overwrite).to.be(true); + expect(results[0].id).to.be(createResponse.body.id); + + const getResponse = await supertestWithoutAuth + .get(`/access_control_objects/${results[0].id}`) + .set('kbn-xsrf', 'true') + .set('cookie', testUserCookie.cookieString()) + .expect(200); + + expect(getResponse.body.attributes).to.have.property('description', 'overwritten'); + expect(getResponse.body).to.have.property('accessControl'); + expect(getResponse.body.accessControl).to.have.property('accessMode', 'write_restricted'); + expect(getResponse.body.accessControl).to.have.property('owner', testProfileId); // retain the original owner + }); + }); + + // Note: This will be implemented in a follow-up phase + // `should apply the owner and access mode from file when 'apply access mode from file' is true` + // describe(`apply access mode from file`, () => { + // it('should reject import of objects with access control metadata that is missing mode', async () => { + // const { cookie: objectOwnerCookie } = await loginAsObjectOwner('test_user', 'changeme'); + + // const toImport = [ + // { + // accessControl: { owner: '' }, // MISSING ACCESS CONTROL META MODE + // attributes: { description: 'test' }, + // coreMigrationVersion: '8.8.0', + // created_at: '2025-07-16T10:03:03.253Z', + // created_by: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + // id: '11111111111111111111111111111111d', + // managed: false, + // references: [], + // type: ACCESS_CONTROL_TYPE, + // updated_at: '2025-07-16T10:03:03.253Z', + // updated_by: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + // version: 'WzY5LDFd', + // }, + // { + // excludedObjects: [], + // excludedObjectsCount: 0, + // exportedCount: 1, + // missingRefCount: 0, + // missingReferences: [], + // }, + // ]; + + // const response = await performImport(toImport, objectOwnerCookie.cookieString()); + + // expect(response.body).not.to.have.property('successResults'); + // const errors = response.body.errors; + // expect(Array.isArray(errors)).to.be(true); + // expect(errors.length).to.be(1); + // expect(errors[0]).to.have.property('error'); + // expect(errors[0].error).to.have.property('type', 'missing_access_control_metadata'); + // }); + // }); + + // it('should reject import of objects with access control metadata if there is no active profile ID', async () => { + // const toImport = [ + // { + // accessControl: { accessMode: 'default', owner: '' }, + // attributes: { description: 'test' }, + // coreMigrationVersion: '8.8.0', + // created_at: '2025-07-16T10:03:03.253Z', + // created_by: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + // id: '11111111111111111111111111111111', + // managed: false, + // references: [], + // type: ACCESS_CONTROL_TYPE, + // updated_at: '2025-07-16T10:03:03.253Z', + // updated_by: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + // version: 'WzY5LDFd', + // }, + // { + // excludedObjects: [], + // excludedObjectsCount: 0, + // exportedCount: 1, + // missingRefCount: 0, + // missingReferences: [], + // }, + // ]; + + // const overwrite = false; + // const createNewCopies = true; + // const requestBody = toImport.map((obj) => JSON.stringify({ ...obj })).join('\n'); + // const query = overwrite + // ? '?overwrite=true' + // : createNewCopies + // ? '?createNewCopies=true' + // : ''; + // const response = await supertest + // .post(`/api/saved_objects/_import${query}`) + // .set('kbn-xsrf', 'true') + // .attach('file', Buffer.from(requestBody, 'utf8'), 'export.ndjson') + // .expect(200); + + // expect(response.body).not.to.have.property('successResults'); + // const errors = response.body.errors; + // expect(Array.isArray(errors)).to.be(true); + // expect(errors.length).to.be(1); + // expect(errors[0]).to.have.property('error'); + // expect(errors[0].error).to.have.property('type', 'requires_profile_id'); + // }); + }); + + describe('#export', () => { + it('should retain all access control metadata', async () => { + const { cookie: testUserCookie, profileUid: testProfileId } = await loginAsObjectOwner( + 'test_user', + 'changeme' + ); + + let createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', testUserCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + expect(createResponse.body.type).to.eql(ACCESS_CONTROL_TYPE); + expect(createResponse.body).to.have.property('accessControl'); + expect(createResponse.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + expect(createResponse.body.accessControl).to.have.property('owner', testProfileId); + const readOnlyId = createResponse.body.id; + + createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', testUserCookie.cookieString()) + .send({ type: NON_ACCESS_CONTROL_TYPE }) + .expect(200); + expect(createResponse.body.type).to.eql(NON_ACCESS_CONTROL_TYPE); + expect(createResponse.body).not.to.have.property('accessControl'); + const nonReadOnlyId = createResponse.body.id; + + const response = await supertestWithoutAuth + .post(`/api/saved_objects/_export`) + .set('kbn-xsrf', 'true') + .set('cookie', testUserCookie.cookieString()) + .send({ + objects: [ + { + type: ACCESS_CONTROL_TYPE, + id: readOnlyId, + }, + { + type: NON_ACCESS_CONTROL_TYPE, + id: nonReadOnlyId, + }, + ], + }) + .expect(200); + + const results = response.text.split('\n').map((str: string) => JSON.parse(str)); + expect(Array.isArray(results)).to.be(true); + expect(results.length).to.be(3); + + expect(results[0]).to.have.property('id', readOnlyId); + expect(results[0]).to.have.property('accessControl'); + expect(results[0].accessControl).to.have.property('accessMode', 'write_restricted'); + expect(results[0].accessControl).to.have.property('owner', testProfileId); + + expect(results[1]).to.have.property('id', nonReadOnlyId); + expect(results[1]).not.to.have.property('accessControl'); + + expect(results[2]).to.have.property('exportedCount', 2); + }); + }); + + describe('#resolve_import_errors', () => { + it(`should allow 'createNewCopies' global option`, async () => { + const { cookie: adminCookie, profileUid: adminProfileId } = await loginAsKibanaAdmin(); + + let createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + expect(createResponse.body.type).to.eql(ACCESS_CONTROL_TYPE); + expect(createResponse.body).to.have.property('accessControl'); + expect(createResponse.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + expect(createResponse.body.accessControl).to.have.property('owner', adminProfileId); + const adminObjId = createResponse.body.id; + + const { cookie: testUserCookie, profileUid: testProfileId } = await loginAsNotObjectOwner( + 'test_user', + 'changeme' + ); + + createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', testUserCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + expect(createResponse.body.type).to.eql(ACCESS_CONTROL_TYPE); + expect(createResponse.body).to.have.property('accessControl'); + expect(createResponse.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + expect(createResponse.body.accessControl).to.have.property('owner', testProfileId); + const testUserObjId = createResponse.body.id; + + const toImport = [ + { + // this first object will import ok + accessControl: { accessMode: 'write_restricted', owner: testProfileId }, + attributes: { description: 'test' }, + coreMigrationVersion: '8.8.0', + created_at: '2025-07-16T10:03:03.253Z', + created_by: testProfileId, + id: testUserObjId, + managed: false, + references: [], + type: ACCESS_CONTROL_TYPE, + updated_at: '2025-07-16T10:03:03.253Z', + updated_by: testProfileId, + version: 'WzY5LDFd', + }, + { + // this second object will be rejected because it is owned by another user + accessControl: { accessMode: 'write_restricted', owner: adminProfileId }, + attributes: { description: 'test' }, + coreMigrationVersion: '8.8.0', + created_at: '2025-07-16T10:03:03.253Z', + created_by: adminProfileId, + id: adminObjId, + managed: false, + references: [], + type: ACCESS_CONTROL_TYPE, + updated_at: '2025-07-16T10:03:03.253Z', + updated_by: adminProfileId, + version: 'WzY5LDFd', + }, + { + excludedObjects: [], + excludedObjectsCount: 0, + exportedCount: 2, + missingRefCount: 0, + missingReferences: [], + }, + ]; + + const importResponse = await performResolveImportErrors( + toImport, + testUserCookie.cookieString(), + [ + { + type: ACCESS_CONTROL_TYPE, + id: testUserObjId, + overwrite: true, // retry will never occur + replaceReferences: [], + }, + { + type: ACCESS_CONTROL_TYPE, + id: adminObjId, + overwrite: true, // retry will never occur + replaceReferences: [], + }, + ], + true, // createNewCopies = true + 200 + ); + + const results = importResponse.text.split('\n').map((str: string) => JSON.parse(str)); + expect(Array.isArray(results)).to.be(true); + expect(results.length).to.be(1); + const result = results[0]; + expect(result).to.have.property('successCount', 2); + expect(result).to.have.property('success', true); + expect(result).to.have.property('successResults'); + expect(Array.isArray(result.successResults)).to.be(true); + expect(result.successResults.length).to.be(2); + + expect(result.successResults[0]).to.have.property('type', ACCESS_CONTROL_TYPE); + expect(result.successResults[0]).to.have.property('id', testUserObjId); + expect(result.successResults[0]).to.have.property('managed', false); + expect(result.successResults[0]).to.have.property('overwrite', true); + expect(result.successResults[0]).to.have.property('destinationId'); // generated ID for new copy + + expect(result.successResults[1]).to.have.property('type', ACCESS_CONTROL_TYPE); + expect(result.successResults[1]).to.have.property('id', adminObjId); + expect(result.successResults[1]).to.have.property('managed', false); + expect(result.successResults[1]).to.have.property('overwrite', true); + expect(result.successResults[1]).to.have.property('destinationId'); // generated ID for new copy + }); + + it('should disallow overwrite retry for write-restricted objects not owned by the current user', async () => { + const { cookie: adminCookie, profileUid: adminProfileId } = await loginAsKibanaAdmin(); + + let createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + expect(createResponse.body.type).to.eql(ACCESS_CONTROL_TYPE); + expect(createResponse.body).to.have.property('accessControl'); + expect(createResponse.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + expect(createResponse.body.accessControl).to.have.property('owner', adminProfileId); + const adminObjId = createResponse.body.id; + + const { cookie: testUserCookie, profileUid: testProfileId } = await loginAsNotObjectOwner( + 'test_user', + 'changeme' + ); + + createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', testUserCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + expect(createResponse.body.type).to.eql(ACCESS_CONTROL_TYPE); + expect(createResponse.body).to.have.property('accessControl'); + expect(createResponse.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + expect(createResponse.body.accessControl).to.have.property('owner', testProfileId); + const testUserObjId = createResponse.body.id; + + const toImport = [ + { + // this first object will import ok + accessControl: { accessMode: 'write_restricted', owner: testProfileId }, + attributes: { description: 'test' }, + coreMigrationVersion: '8.8.0', + created_at: '2025-07-16T10:03:03.253Z', + created_by: testProfileId, + id: testUserObjId, + managed: false, + references: [], + type: ACCESS_CONTROL_TYPE, + updated_at: '2025-07-16T10:03:03.253Z', + updated_by: testProfileId, + version: 'WzY5LDFd', + }, + { + // this second object will be rejected because it is owned by another user + accessControl: { accessMode: 'write_restricted', owner: adminProfileId }, + attributes: { description: 'test' }, + coreMigrationVersion: '8.8.0', + created_at: '2025-07-16T10:03:03.253Z', + created_by: adminProfileId, + id: adminObjId, + managed: false, + references: [], + type: ACCESS_CONTROL_TYPE, + updated_at: '2025-07-16T10:03:03.253Z', + updated_by: adminProfileId, + version: 'WzY5LDFd', + }, + { + excludedObjects: [], + excludedObjectsCount: 0, + exportedCount: 2, + missingRefCount: 0, + missingReferences: [], + }, + ]; + + const importResponse = await performResolveImportErrors( + toImport, + testUserCookie.cookieString(), + [ + { + type: ACCESS_CONTROL_TYPE, + id: testUserObjId, + overwrite: true, + replaceReferences: [], + }, + { + type: ACCESS_CONTROL_TYPE, + id: adminObjId, + overwrite: true, + replaceReferences: [], + }, + ], + false, // createNewCopies = false + 200 + ); + const results = importResponse.text.split('\n').map((str: string) => JSON.parse(str)); + expect(Array.isArray(results)).to.be(true); + expect(results.length).to.be(1); + const result = results[0]; + expect(result).to.have.property('successCount', 1); + expect(result).to.have.property('success', false); + expect(result).to.have.property('successResults'); + expect(result.successResults).to.eql([ + { + type: ACCESS_CONTROL_TYPE, + id: testUserObjId, + meta: {}, + managed: false, + overwrite: true, + }, + ]); + expect(result).to.have.property('errors'); + expect(result.errors).to.eql([ + { + id: adminObjId, + type: ACCESS_CONTROL_TYPE, + meta: {}, + error: { + message: + 'Overwriting objects in "write_restricted" mode that are owned by another user requires the "manage_access_control" privilege.', + statusCode: 403, + error: 'Forbidden', + type: 'unknown', + }, + overwrite: true, + }, + ]); + }); + + it('should disallow create new retry with same ID for write-restricted objects not owned by the current user', async () => { + const { cookie: adminCookie, profileUid: adminProfileId } = await loginAsKibanaAdmin(); + + let createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + expect(createResponse.body.type).to.eql(ACCESS_CONTROL_TYPE); + expect(createResponse.body).to.have.property('accessControl'); + expect(createResponse.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + expect(createResponse.body.accessControl).to.have.property('owner', adminProfileId); + const adminObjId = createResponse.body.id; + + const { cookie: testUserCookie, profileUid: testProfileId } = await loginAsNotObjectOwner( + 'test_user', + 'changeme' + ); + + createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', testUserCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + expect(createResponse.body.type).to.eql(ACCESS_CONTROL_TYPE); + expect(createResponse.body).to.have.property('accessControl'); + expect(createResponse.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + expect(createResponse.body.accessControl).to.have.property('owner', testProfileId); + const testUserObjId = createResponse.body.id; + + const toImport = [ + { + // this first object will import ok + accessControl: { accessMode: 'write_restricted', owner: testProfileId }, + attributes: { description: 'test' }, + coreMigrationVersion: '8.8.0', + created_at: '2025-07-16T10:03:03.253Z', + created_by: testProfileId, + id: testUserObjId, + managed: false, + references: [], + type: ACCESS_CONTROL_TYPE, + updated_at: '2025-07-16T10:03:03.253Z', + updated_by: testProfileId, + version: 'WzY5LDFd', + }, + { + // this second object will be rejected because it is owned by another user + accessControl: { accessMode: 'write_restricted', owner: adminProfileId }, + attributes: { description: 'test' }, + coreMigrationVersion: '8.8.0', + created_at: '2025-07-16T10:03:03.253Z', + created_by: adminProfileId, + id: adminObjId, + managed: false, + references: [], + type: ACCESS_CONTROL_TYPE, + updated_at: '2025-07-16T10:03:03.253Z', + updated_by: adminProfileId, + version: 'WzY5LDFd', + }, + { + excludedObjects: [], + excludedObjectsCount: 0, + exportedCount: 2, + missingRefCount: 0, + missingReferences: [], + }, + ]; + + const importResponse = await performResolveImportErrors( + toImport, + testUserCookie.cookieString(), + [ + { + type: ACCESS_CONTROL_TYPE, + id: testUserObjId, + overwrite: true, + replaceReferences: [], + }, + { + type: ACCESS_CONTROL_TYPE, + id: adminObjId, + overwrite: false, + createNewCopy: true, + replaceReferences: [], + }, + ], + false, // createNewCopies = false + 200 + ); + + const results = importResponse.text.split('\n').map((str: string) => JSON.parse(str)); + expect(Array.isArray(results)).to.be(true); + expect(results.length).to.be(1); + const result = results[0]; + expect(result).to.have.property('successCount', 1); + expect(result).to.have.property('success', false); + expect(result).to.have.property('successResults'); + expect(result.successResults).to.eql([ + { + type: ACCESS_CONTROL_TYPE, + id: testUserObjId, + meta: {}, + managed: false, + overwrite: true, + }, + ]); + expect(result).to.have.property('errors'); + expect(result.errors).to.eql([ + { + id: adminObjId, + type: ACCESS_CONTROL_TYPE, + meta: {}, + error: { + type: 'conflict', + }, + }, + ]); + }); + + it('should allow create new retry with destiantion ID for write-restricted objects not owned by the current user', async () => { + const { cookie: adminCookie, profileUid: adminProfileId } = await loginAsKibanaAdmin(); + + let createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', adminCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + expect(createResponse.body.type).to.eql(ACCESS_CONTROL_TYPE); + expect(createResponse.body).to.have.property('accessControl'); + expect(createResponse.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + expect(createResponse.body.accessControl).to.have.property('owner', adminProfileId); + const adminObjId = createResponse.body.id; + + const { cookie: testUserCookie, profileUid: testProfileId } = await loginAsNotObjectOwner( + 'test_user', + 'changeme' + ); + + createResponse = await supertestWithoutAuth + .post('/access_control_objects/create') + .set('kbn-xsrf', 'true') + .set('cookie', testUserCookie.cookieString()) + .send({ type: ACCESS_CONTROL_TYPE, isWriteRestricted: true }) + .expect(200); + expect(createResponse.body.type).to.eql(ACCESS_CONTROL_TYPE); + expect(createResponse.body).to.have.property('accessControl'); + expect(createResponse.body.accessControl).to.have.property( + 'accessMode', + 'write_restricted' + ); + expect(createResponse.body.accessControl).to.have.property('owner', testProfileId); + const testUserObjId = createResponse.body.id; + + const toImport = [ + { + // this first object will import ok + accessControl: { accessMode: 'write_restricted', owner: testProfileId }, + attributes: { description: 'test' }, + coreMigrationVersion: '8.8.0', + created_at: '2025-07-16T10:03:03.253Z', + created_by: testProfileId, + id: testUserObjId, + managed: false, + references: [], + type: ACCESS_CONTROL_TYPE, + updated_at: '2025-07-16T10:03:03.253Z', + updated_by: testProfileId, + version: 'WzY5LDFd', + }, + { + // this second object will be rejected because it is owned by another user + accessControl: { accessMode: 'write_restricted', owner: adminProfileId }, + attributes: { description: 'test' }, + coreMigrationVersion: '8.8.0', + created_at: '2025-07-16T10:03:03.253Z', + created_by: adminProfileId, + id: adminObjId, + managed: false, + references: [], + type: ACCESS_CONTROL_TYPE, + updated_at: '2025-07-16T10:03:03.253Z', + updated_by: adminProfileId, + version: 'WzY5LDFd', + }, + { + excludedObjects: [], + excludedObjectsCount: 0, + exportedCount: 2, + missingRefCount: 0, + missingReferences: [], + }, + ]; + + const destinationId = adminObjId + '_new'; + + const importResponse = await performResolveImportErrors( + toImport, + testUserCookie.cookieString(), + [ + { + type: ACCESS_CONTROL_TYPE, + id: testUserObjId, + overwrite: true, + replaceReferences: [], + }, + { + type: ACCESS_CONTROL_TYPE, + id: adminObjId, + overwrite: false, + createNewCopy: true, + replaceReferences: [], + destinationId, + }, + ], + false, // createNewCopies = false + 200 + ); + + const results = importResponse.text.split('\n').map((str: string) => JSON.parse(str)); + expect(Array.isArray(results)).to.be(true); + expect(results.length).to.be(1); + const result = results[0]; + expect(result).to.have.property('successCount', 2); + expect(result).to.have.property('success', true); + expect(result).to.have.property('successResults'); + expect(result.successResults).to.eql([ + { + type: ACCESS_CONTROL_TYPE, + id: testUserObjId, + meta: {}, + managed: false, + overwrite: true, + }, + { + type: ACCESS_CONTROL_TYPE, + id: adminObjId, + destinationId, + meta: {}, + managed: false, + createNewCopy: true, + }, + ]); + }); + }); + }); +} diff --git a/x-pack/platform/test/spaces_api_integration/access_control_objects/config.ts b/x-pack/platform/test/spaces_api_integration/access_control_objects/config.ts new file mode 100644 index 0000000000000..2291bc7d422a2 --- /dev/null +++ b/x-pack/platform/test/spaces_api_integration/access_control_objects/config.ts @@ -0,0 +1,67 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { resolve } from 'path'; + +import type { FtrConfigProviderContext } from '@kbn/test'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const kibanaAPITestsConfig = await readConfigFile( + require.resolve('@kbn/test-suites-src/api_integration/config') + ); + const xPackAPITestsConfig = await readConfigFile( + require.resolve('../../api_integration/config.ts') + ); + + const accessControlTestPlugin = resolve( + __dirname, + '../common/plugins/access_control_test_plugin' + ); + + return { + testFiles: [ + resolve(__dirname, './apis/spaces/access_control_objects.ts'), + resolve(__dirname, './apis/spaces/import_export.ts'), + ], + services: { + ...kibanaAPITestsConfig.get('services'), + ...xPackAPITestsConfig.get('services'), + }, + servers: xPackAPITestsConfig.get('servers'), + esTestCluster: xPackAPITestsConfig.get('esTestCluster'), + + kbnTestServer: { + ...xPackAPITestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + `--plugin-path=${accessControlTestPlugin}`, + `--savedObjects.enableAccessControl=true`, + ], + }, + security: { + ...xPackAPITestsConfig.get('security'), + roles: { + ...xPackAPITestsConfig.get('security.roles'), + kibana_savedobjects_editor: { + kibana: [ + { + base: [], + feature: { + dev_tools: ['all'], + savedObjectsManagement: ['all'], + }, + spaces: ['*'], + }, + ], + }, + }, + }, + junit: { + reportName: 'X-Pack Security API Integration Tests (Read Only Saved Objects)', + }, + }; +} diff --git a/x-pack/platform/test/spaces_api_integration/access_control_objects/feature_disabled.config.ts b/x-pack/platform/test/spaces_api_integration/access_control_objects/feature_disabled.config.ts new file mode 100644 index 0000000000000..ae83b01aaa9bd --- /dev/null +++ b/x-pack/platform/test/spaces_api_integration/access_control_objects/feature_disabled.config.ts @@ -0,0 +1,64 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { resolve } from 'path'; + +import type { FtrConfigProviderContext } from '@kbn/test'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const kibanaAPITestsConfig = await readConfigFile( + require.resolve('@kbn/test-suites-src/api_integration/config') + ); + const xPackAPITestsConfig = await readConfigFile( + require.resolve('../../api_integration/config.ts') + ); + + const accessControlTestPlugin = resolve( + __dirname, + '../common/plugins/access_control_test_plugin' + ); + + return { + testFiles: [resolve(__dirname, './apis/spaces/feature_disabled.ts')], + services: { + ...kibanaAPITestsConfig.get('services'), + ...xPackAPITestsConfig.get('services'), + }, + servers: xPackAPITestsConfig.get('servers'), + esTestCluster: xPackAPITestsConfig.get('esTestCluster'), + + kbnTestServer: { + ...xPackAPITestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + `--plugin-path=${accessControlTestPlugin}`, + `--savedObjects.enableAccessControl=false`, + ], + }, + security: { + ...xPackAPITestsConfig.get('security'), + roles: { + ...xPackAPITestsConfig.get('security.roles'), + kibana_savedobjects_editor: { + kibana: [ + { + base: [], + feature: { + dev_tools: ['all'], + savedObjectsManagement: ['all'], + }, + spaces: ['*'], + }, + ], + }, + }, + }, + junit: { + reportName: 'X-Pack Security API Integration Tests (Read Only Saved Objects)', + }, + }; +} diff --git a/x-pack/platform/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/access_control/data.json b/x-pack/platform/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/access_control/data.json new file mode 100644 index 0000000000000..0736346773ee2 --- /dev/null +++ b/x-pack/platform/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/access_control/data.json @@ -0,0 +1,25 @@ +{ + "_index": ".kibana_9.3.0_001", + "_id": "access_control_type:ac_1", + "_version": 4, + "_seq_no": 37, + "_primary_term": 1, + "found": true, + "_source": { + "access_control_type": { + "description": "This is the first test access control object" + }, + "type": "access_control_type", + "updated_at": "2017-09-21T18:49:16.270Z", + "updated_by": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0", + "created_at": "2017-09-21T18:49:16.270Z", + "created_by": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0", + "coreMigrationVersion": "8.8.0", + "managed": false, + "references": [], + "accessControl": { + "owner": "u_nonexisting_version", + "accessMode": "write_restricted" + } + } +} diff --git a/x-pack/platform/test/spaces_api_integration/common/plugins/access_control_test_plugin/kibana.jsonc b/x-pack/platform/test/spaces_api_integration/common/plugins/access_control_test_plugin/kibana.jsonc new file mode 100644 index 0000000000000..ff5ac7d553ce3 --- /dev/null +++ b/x-pack/platform/test/spaces_api_integration/common/plugins/access_control_test_plugin/kibana.jsonc @@ -0,0 +1,10 @@ +{ + "type": "plugin", + "id": "@kbn/access-control-test-plugin", + "owner": "@elastic/kibana-security", + "plugin": { + "id": "accessControlTestPlugin", + "server": true, + "browser": false + } +} diff --git a/x-pack/platform/test/spaces_api_integration/common/plugins/access_control_test_plugin/moon.yml b/x-pack/platform/test/spaces_api_integration/common/plugins/access_control_test_plugin/moon.yml new file mode 100644 index 0000000000000..232f7dfcc8642 --- /dev/null +++ b/x-pack/platform/test/spaces_api_integration/common/plugins/access_control_test_plugin/moon.yml @@ -0,0 +1,33 @@ +# This file is generated by the @kbn/moon package. Any manual edits will be erased! +# To extend this, write your extensions/overrides to 'moon.extend.yml' +# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/access-control-test-plugin' + +$schema: https://moonrepo.dev/schemas/project.json +id: '@kbn/access-control-test-plugin' +type: unknown +owners: + defaultOwner: '@elastic/kibana-security' +toolchain: + default: node +language: typescript +project: + name: '@kbn/access-control-test-plugin' + description: Moon project for @kbn/access-control-test-plugin + channel: '' + owner: '@elastic/kibana-security' + metadata: + sourceRoot: x-pack/platform/test/spaces_api_integration/common/plugins/access_control_test_plugin +dependsOn: + - '@kbn/core' + - '@kbn/config-schema' + - '@kbn/core-saved-objects-server' +tags: + - plugin + - prod + - group-undefined +fileGroups: + src: + - '**/*.ts' + - '**/*.tsx' + - '!target/**/*' +tasks: {} diff --git a/x-pack/platform/test/spaces_api_integration/common/plugins/access_control_test_plugin/server/index.ts b/x-pack/platform/test/spaces_api_integration/common/plugins/access_control_test_plugin/server/index.ts new file mode 100644 index 0000000000000..926b01b1346be --- /dev/null +++ b/x-pack/platform/test/spaces_api_integration/common/plugins/access_control_test_plugin/server/index.ts @@ -0,0 +1,11 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AccessControlTestPlugin } from './plugin'; + +export const plugin = async () => new AccessControlTestPlugin(); +export { ACCESS_CONTROL_TYPE, NON_ACCESS_CONTROL_TYPE } from './plugin'; diff --git a/x-pack/platform/test/spaces_api_integration/common/plugins/access_control_test_plugin/server/plugin.ts b/x-pack/platform/test/spaces_api_integration/common/plugins/access_control_test_plugin/server/plugin.ts new file mode 100644 index 0000000000000..baa33f3e4251e --- /dev/null +++ b/x-pack/platform/test/spaces_api_integration/common/plugins/access_control_test_plugin/server/plugin.ts @@ -0,0 +1,574 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import type { CoreSetup, Plugin } from '@kbn/core/server'; +import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; + +export const ACCESS_CONTROL_TYPE = 'access_control_type'; +export const NON_ACCESS_CONTROL_TYPE = 'non_access_control_type'; + +export class AccessControlTestPlugin implements Plugin { + public setup(core: CoreSetup) { + core.savedObjects.registerType({ + name: ACCESS_CONTROL_TYPE, + hidden: false, + namespaceType: 'multiple-isolated', + supportsAccessControl: true, + management: { + importableAndExportable: true, + }, + mappings: { + dynamic: false, + properties: { + description: { type: 'text' }, + }, + }, + }); + + core.savedObjects.registerType({ + name: NON_ACCESS_CONTROL_TYPE, + hidden: false, + namespaceType: 'multiple-isolated', + management: { + importableAndExportable: true, + }, + mappings: { + dynamic: false, + properties: { + description: { type: 'text' }, + }, + }, + }); + + const router = core.http.createRouter(); + // Create + router.post( + { + path: '/access_control_objects/create', + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + }, + validate: { + query: schema.object({ + overwrite: schema.boolean({ + defaultValue: false, + }), + }), + body: schema.object({ + id: schema.maybe(schema.string()), + type: schema.maybe( + schema.oneOf([ + schema.literal(ACCESS_CONTROL_TYPE), + schema.literal(NON_ACCESS_CONTROL_TYPE), + ]) + ), + isWriteRestricted: schema.maybe(schema.boolean()), + description: schema.maybe(schema.string()), + }), + }, + }, + async (context, request, response) => { + const soClient = (await context.core).savedObjects.getClient(); + const objType = request.body.type || ACCESS_CONTROL_TYPE; + const { isWriteRestricted, description } = request.body; + + const options = { + overwrite: request.query.overwrite ?? false, + ...(request.body.id ? { id: request.body.id } : {}), + ...(isWriteRestricted + ? { accessControl: { accessMode: 'write_restricted' as const } } + : {}), + }; + try { + const result = await soClient.create( + objType, + { + description: description ?? 'test', + }, + options + ); + + return response.ok({ + body: result, + }); + } catch (error) { + if (SavedObjectsErrorHelpers.isSavedObjectsClientError(error)) { + return response.customError({ + statusCode: error.output.statusCode, + body: { + message: error.message, + }, + }); + } + throw error; + } + } + ); + + // Bulk Create + router.post( + { + path: '/access_control_objects/bulk_create', + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + }, + validate: { + request: { + query: schema.object({ + overwrite: schema.boolean({ + defaultValue: false, + }), + }), + body: schema.object({ + objects: schema.arrayOf( + schema.object({ + id: schema.maybe(schema.string()), + type: schema.maybe( + schema.oneOf([ + schema.literal(ACCESS_CONTROL_TYPE), + schema.literal(NON_ACCESS_CONTROL_TYPE), + ]) + ), + isWriteRestricted: schema.maybe(schema.boolean()), + description: schema.maybe(schema.string()), + }) + ), + force: schema.maybe(schema.boolean({ defaultValue: false })), + }), + }, + }, + }, + async (context, request, response) => { + const soClient = (await context.core).savedObjects.client; + const overwrite = request.query.overwrite ?? false; + try { + const createObjects = request.body.objects.map((obj) => ({ + type: obj.type || ACCESS_CONTROL_TYPE, + ...(obj.id ? { id: obj.id } : {}), + ...(obj.isWriteRestricted + ? { accessControl: { accessMode: 'write_restricted' as const } } + : {}), + attributes: { + description: obj.description ?? 'description', + }, + })); + const result = await soClient.bulkCreate(createObjects, { overwrite }); + return response.ok({ + body: result, + }); + } catch (error) { + if (SavedObjectsErrorHelpers.isSavedObjectsClientError(error)) { + return response.customError({ + statusCode: error.output.statusCode, + body: { + message: error.message, + }, + }); + } + throw error; + } + } + ); + + router.get( + { + path: '/access_control_objects/_find', + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + }, + validate: false, + }, + async (context, request, response) => { + const soClient = (await context.core).savedObjects.client; + const result = await soClient.find({ + type: ACCESS_CONTROL_TYPE, + }); + return response.ok({ + body: result, + }); + } + ); + router.get( + { + path: '/access_control_objects/{objectId}', + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + }, + validate: { + params: schema.object({ + objectId: schema.string(), + }), + }, + }, + async (context, request, response) => { + try { + const soClient = (await context.core).savedObjects.client; + const result = await soClient.get(ACCESS_CONTROL_TYPE, request.params.objectId); + return response.ok({ + body: result, + }); + } catch (error) { + if (SavedObjectsErrorHelpers.isSavedObjectsClientError(error)) { + return response.customError({ + statusCode: error.output.statusCode, + body: { + message: error.message, + }, + }); + } + throw error; + } + } + ); + + router.put( + { + path: '/access_control_objects/update', + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + }, + validate: { + body: schema.object({ + type: schema.oneOf([ + schema.literal(ACCESS_CONTROL_TYPE), + schema.literal(NON_ACCESS_CONTROL_TYPE), + ]), + objectId: schema.string(), + }), + }, + }, + async (context, request, response) => { + const soClient = (await context.core).savedObjects.client; + try { + const objectType = request.body.type || ACCESS_CONTROL_TYPE; + const result = await soClient.update(objectType, request.body.objectId, { + description: 'updated description', + }); + + return response.ok({ + body: result, + }); + } catch (error) { + if (SavedObjectsErrorHelpers.isSavedObjectsClientError(error)) { + return response.customError({ + statusCode: error.output.statusCode, + body: { + message: error.message, + }, + }); + } + throw error; + } + } + ); + router.put( + { + path: '/access_control_objects/change_owner', + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + }, + validate: { + body: schema.object({ + newOwnerProfileUid: schema.string(), + objects: schema.arrayOf( + schema.object({ + type: schema.oneOf([ + schema.literal(ACCESS_CONTROL_TYPE), + schema.literal(NON_ACCESS_CONTROL_TYPE), + ]), + id: schema.string(), + }) + ), + }), + }, + }, + async (context, request, response) => { + const soClient = (await context.core).savedObjects.client; + + try { + const result = await soClient.changeOwnership(request.body.objects, { + newOwnerProfileUid: request.body.newOwnerProfileUid, + }); + return response.ok({ + body: result, + }); + } catch (error) { + if (SavedObjectsErrorHelpers.isSavedObjectsClientError(error)) { + return response.customError({ + statusCode: error.output.statusCode, + body: { + message: error.message, + }, + }); + } + throw error; + } + } + ); + router.put( + { + path: '/access_control_objects/change_access_mode', + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + }, + validate: { + body: schema.object({ + objects: schema.arrayOf( + schema.object({ + id: schema.string(), + type: schema.oneOf([ + schema.literal(ACCESS_CONTROL_TYPE), + schema.literal(NON_ACCESS_CONTROL_TYPE), + ]), + }) + ), + newAccessMode: schema.oneOf([ + schema.literal('write_restricted'), + schema.literal('default'), + ]), + }), + }, + }, + async (context, request, response) => { + const soClient = (await context.core).savedObjects.client; + try { + const result = await soClient.changeAccessMode(request.body.objects, { + accessMode: request.body.newAccessMode, + }); + return response.ok({ + body: result, + }); + } catch (error) { + if (SavedObjectsErrorHelpers.isSavedObjectsClientError(error)) { + return response.customError({ + statusCode: error.output.statusCode, + body: { + message: error.message, + }, + }); + } + throw error; + } + } + ); + + router.delete( + { + path: '/access_control_objects/{objectId}', + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + }, + validate: { + params: schema.object({ + objectId: schema.string(), + }), + }, + }, + async (context, request, response) => { + try { + const soClient = (await context.core).savedObjects.client; + const result = await soClient.delete(ACCESS_CONTROL_TYPE, request.params.objectId); + return response.ok({ + body: result, + }); + } catch (error) { + if (SavedObjectsErrorHelpers.isSavedObjectsClientError(error)) { + return response.customError({ + statusCode: error.output.statusCode, + body: { + message: error.message, + }, + }); + } + throw error; + } + } + ); + + router.post( + { + path: '/access_control_objects/bulk_delete', + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + }, + validate: { + body: schema.object({ + objects: schema.arrayOf( + schema.object({ + id: schema.string(), + type: schema.oneOf([ + schema.literal(ACCESS_CONTROL_TYPE), + schema.literal(NON_ACCESS_CONTROL_TYPE), + ]), + }) + ), + force: schema.maybe(schema.boolean({ defaultValue: false })), + }), + }, + }, + async (context, request, response) => { + const soClient = (await context.core).savedObjects.client; + try { + const result = await soClient.bulkDelete(request.body.objects, { + force: request.body.force, + }); + return response.ok({ + body: result, + }); + } catch (error) { + if (SavedObjectsErrorHelpers.isSavedObjectsClientError(error)) { + return response.customError({ + statusCode: error.output.statusCode, + body: { + message: error.message, + }, + }); + } + throw error; + } + } + ); + router.post( + { + path: '/access_control_objects/bulk_update', + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + }, + validate: { + body: schema.object({ + objects: schema.arrayOf( + schema.object({ + id: schema.string(), + type: schema.oneOf([ + schema.literal(ACCESS_CONTROL_TYPE), + schema.literal(NON_ACCESS_CONTROL_TYPE), + ]), + }) + ), + force: schema.maybe(schema.boolean({ defaultValue: false })), + }), + }, + }, + async (context, request, response) => { + const soClient = (await context.core).savedObjects.client; + try { + const updateObjects = request.body.objects.map((obj) => ({ + ...obj, + attributes: { + description: 'updated description', + }, + })); + const result = await soClient.bulkUpdate(updateObjects); + return response.ok({ + body: result, + }); + } catch (error) { + if (SavedObjectsErrorHelpers.isSavedObjectsClientError(error)) { + return response.customError({ + statusCode: error.output.statusCode, + body: { + message: error.message, + }, + }); + } + throw error; + } + } + ); + + // Get NON_ACCESS_CONTROL_TYPE + router.get( + { + path: '/non_access_control_objects/{objectId}', + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + }, + validate: { + params: schema.object({ + objectId: schema.string(), + }), + }, + }, + async (context, request, response) => { + try { + const soClient = (await context.core).savedObjects.client; + const result = await soClient.get(NON_ACCESS_CONTROL_TYPE, request.params.objectId); + return response.ok({ + body: result, + }); + } catch (error) { + if (error.output && error.output.statusCode === 404) { + return response.notFound({ + body: error.message, + }); + } + return response.forbidden({ + body: error.message, + }); + } + } + ); + + // FIND NON_ACCESS_CONTROL_TYPE + router.get( + { + path: '/non_access_control_objects/_find', + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + }, + validate: false, + }, + async (context, request, response) => { + const soClient = (await context.core).savedObjects.client; + const result = await soClient.find({ + type: NON_ACCESS_CONTROL_TYPE, + }); + return response.ok({ + body: result, + }); + } + ); + } + public start() {} +} diff --git a/x-pack/platform/test/spaces_api_integration/common/plugins/access_control_test_plugin/tsconfig.json b/x-pack/platform/test/spaces_api_integration/common/plugins/access_control_test_plugin/tsconfig.json new file mode 100644 index 0000000000000..fadd337e826fd --- /dev/null +++ b/x-pack/platform/test/spaces_api_integration/common/plugins/access_control_test_plugin/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@kbn/tsconfig-base/tsconfig.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["target/**/*"], + "kbn_references": [ + "@kbn/core", + "@kbn/config-schema", + "@kbn/core-saved-objects-server" + ] +} diff --git a/x-pack/platform/test/tsconfig.json b/x-pack/platform/test/tsconfig.json index 66cba0165eb46..4fb3dd8935c2f 100644 --- a/x-pack/platform/test/tsconfig.json +++ b/x-pack/platform/test/tsconfig.json @@ -169,6 +169,7 @@ "@kbn/onechat-plugin", "@kbn/field-formats-common", "@kbn/reporting-test-routes", + "@kbn/access-control-test-plugin", "@kbn/alerting-types", "@kbn/visualizations-common", "@kbn/test-subj-selector", diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/utils/index.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/utils/index.ts index 0a5dd001d53c7..aa6d9d34a2804 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/utils/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/utils/index.ts @@ -33,6 +33,12 @@ export const mapSOErrorToBulkError = (error: SavedObjectsImportFailure): BulkErr statusCode: 400, message: 'Missing SO references', }); + case 'unexpected_access_control_metadata': + return createBulkErrorObject({ + id: error.id, + statusCode: 400, + message: 'Unexpected access control metadata for object type', + }); case 'unknown': return createBulkErrorObject({ id: error.id, diff --git a/yarn.lock b/yarn.lock index 023a108f50b33..1191b0e7f5a28 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4530,6 +4530,10 @@ version "0.0.0" uid "" +"@kbn/access-control-test-plugin@link:x-pack/platform/test/spaces_api_integration/common/plugins/access_control_test_plugin": + version "0.0.0" + uid "" + "@kbn/actions-plugin@link:x-pack/platform/plugins/shared/actions": version "0.0.0" uid "" @@ -5006,6 +5010,14 @@ version "0.0.0" uid "" +"@kbn/content-management-access-control-public@link:src/platform/packages/shared/content-management/access_control/access_control_public": + version "0.0.0" + uid "" + +"@kbn/content-management-access-control-server@link:src/platform/packages/shared/content-management/access_control/access_control_server": + version "0.0.0" + uid "" + "@kbn/content-management-content-editor@link:src/platform/packages/shared/content-management/content_editor": version "0.0.0" uid ""