diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index 052f6623dc1a6b..b5e62a9c5c7b43 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -368,6 +368,7 @@ enabled: - x-pack/test/saved_object_api_integration/security_and_spaces/config_basic.ts - x-pack/test/saved_object_api_integration/security_and_spaces/config_trial.ts - x-pack/test/saved_object_api_integration/spaces_only/config.ts + - x-pack/test/saved_object_api_integration/user_profiles/config.ts - x-pack/test/saved_object_tagging/api_integration/security_and_spaces/config.ts - x-pack/test/saved_object_tagging/api_integration/tagging_api/config.ts - x-pack/test/saved_object_tagging/api_integration/tagging_usage_collection/config.ts diff --git a/packages/core/saved-objects/core-saved-objects-api-browser/src/simple_saved_object.ts b/packages/core/saved-objects/core-saved-objects-api-browser/src/simple_saved_object.ts index c5918ff16ce615..903c6e2ab5d97e 100644 --- a/packages/core/saved-objects/core-saved-objects-api-browser/src/simple_saved_object.ts +++ b/packages/core/saved-objects/core-saved-objects-api-browser/src/simple_saved_object.ts @@ -42,6 +42,8 @@ export interface SimpleSavedObject { references: SavedObjectType['references']; /** The date this object was last updated */ updatedAt: SavedObjectType['updated_at']; + /** The user that last updated this object */ + updatedBy: SavedObjectType['updated_by']; /** The date this object was created */ createdAt: SavedObjectType['created_at']; /** The user that created this object */ diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_create.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_create.ts index c9b9fdfb768aa7..e5f65665c6a253 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_create.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_create.ts @@ -85,6 +85,7 @@ export const performBulkCreate = async ( } = options; const time = getCurrentTime(); const createdBy = userHelper.getCurrentUserProfileUid(); + const updatedBy = createdBy; let preflightCheckIndexCounter = 0; const expectedResults = objects.map((object) => { @@ -234,6 +235,7 @@ export const performBulkCreate = async ( updated_at: time, created_at: time, ...(createdBy && { created_by: createdBy }), + ...(updatedBy && { updated_by: updatedBy }), references: object.references || [], originId, }) as SavedObjectSanitizedDoc; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts index d618bf7a2c82f0..edaabbe7b5a7c8 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts @@ -82,10 +82,12 @@ export const performBulkUpdate = async ( common: commonHelper, encryption: encryptionHelper, migration: migrationHelper, + user: userHelper, } = helpers; const { securityExtension } = extensions; const { migrationVersionCompatibility } = options; const namespace = commonHelper.getCurrentNamespace(options.namespace); + const updatedBy = userHelper.getCurrentUserProfileUid(); const time = getCurrentTime(); let bulkGetRequestIndexCounter = 0; @@ -120,6 +122,7 @@ export const performBulkUpdate = async ( const documentToSave = { [type]: attributes, updated_at: time, + updated_by: updatedBy, ...(Array.isArray(references) && { references }), }; @@ -304,6 +307,7 @@ export const performBulkUpdate = async ( namespaces, attributes: updatedAttributes, updated_at: time, + updated_by: updatedBy, ...(Array.isArray(documentToSave.references) && { references: documentToSave.references }), }); const updatedMigratedDocumentToSave = serializer.savedObjectToRaw( @@ -364,7 +368,7 @@ export const performBulkUpdate = async ( const { _seq_no: seqNo, _primary_term: primaryTerm } = rawResponse; // eslint-disable-next-line @typescript-eslint/naming-convention - const { [type]: attributes, references, updated_at } = documentToSave; + const { [type]: attributes, references, updated_at, updated_by } = documentToSave; const { originId } = rawMigratedUpdatedDoc._source; return { @@ -373,6 +377,7 @@ export const performBulkUpdate = async ( ...(namespaces && { namespaces }), ...(originId && { originId }), updated_at, + updated_by, version: encodeVersion(seqNo, primaryTerm), attributes, references, diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/create.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/create.ts index ebb6f8e59dbc79..e6e690da3004f5 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/create.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/create.ts @@ -71,6 +71,7 @@ export const performCreate = async ( const time = getCurrentTime(); const createdBy = userHelper.getCurrentUserProfileUid(); + const updatedBy = createdBy; let savedObjectNamespace: string | undefined; let savedObjectNamespaces: string[] | undefined; let existingOriginId: string | undefined; @@ -136,6 +137,7 @@ export const performCreate = async ( created_at: time, updated_at: time, ...(createdBy && { created_by: createdBy }), + ...(updatedBy && { updated_by: updatedBy }), ...(Array.isArray(references) && { references }), }); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/find.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/find.test.ts index a10d4e1f7a30af..755d535b3bd2bf 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/find.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/find.test.ts @@ -149,6 +149,7 @@ describe('find', () => { 'typeMigrationVersion', 'managed', 'updated_at', + 'updated_by', 'created_at', 'created_by', 'originId', diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/update.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/update.test.ts index a5a2d82e2ac185..117a36d3960cf0 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/update.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/update.test.ts @@ -43,6 +43,7 @@ import { createConflictErrorPayload, createGenericNotFoundErrorPayload, updateSuccess, + mockTimestampFieldsWithCreated, } from '../../test_helpers/repository.test.common'; describe('#update', () => { @@ -319,7 +320,7 @@ describe('#update', () => { const expected = { 'index-pattern': { description: 'bar', title: 'foo' }, type: 'index-pattern', - ...mockTimestampFields, + ...mockTimestampFieldsWithCreated, }; expect( (client.create.mock.calls[0][0] as estypes.CreateRequest).body! @@ -352,7 +353,7 @@ describe('#update', () => { multiNamespaceIsolatedType: { description: 'bar', title: 'foo' }, namespaces: ['default'], type: 'multiNamespaceIsolatedType', - ...mockTimestampFields, + ...mockTimestampFieldsWithCreated, }; expect( (client.create.mock.calls[0][0] as estypes.CreateRequest).body! diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/update.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/update.ts index 5c7eb11786db5a..ee45ba5abf7bbd 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/update.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/update.ts @@ -81,6 +81,7 @@ export const executeUpdate = async ( preflight: preflightHelper, migration: migrationHelper, validation: validationHelper, + user: userHelper, } = helpers; const { securityExtension } = extensions; const typeDefinition = registry.getType(type)!; @@ -151,6 +152,7 @@ export const executeUpdate = async ( // END ALL PRE_CLIENT CALL CHECKS && MIGRATE EXISTING DOC; const time = getCurrentTime(); + const updatedBy = userHelper.getCurrentUserProfileUid(); let updatedOrCreatedSavedObject: SavedObject; // `upsert` option set and document was not found -> we need to perform an upsert operation const shouldPerformUpsert = upsert && docNotFound; @@ -176,7 +178,9 @@ export const executeUpdate = async ( attributes: { ...(await encryptionHelper.optionallyEncryptAttributes(type, id, namespace, upsert)), }, + created_at: time, updated_at: time, + ...(updatedBy && { created_by: updatedBy, updated_by: updatedBy }), ...(Array.isArray(references) && { references }), }) as SavedObjectSanitizedDoc; validationHelper.validateObjectForCreate(type, migratedUpsert); @@ -232,7 +236,9 @@ export const executeUpdate = async ( updatedOrCreatedSavedObject = { id, type, + created_at: time, updated_at: time, + ...(updatedBy && { created_by: updatedBy, updated_by: updatedBy }), version: encodeHitVersion(createDocResponseBody), namespaces, ...(originId && { originId }), @@ -273,6 +279,7 @@ export const executeUpdate = async ( namespaces: savedObjectNamespaces, attributes: updatedAttributes, updated_at: time, + updated_by: updatedBy, ...(Array.isArray(references) && { references }), }); @@ -336,6 +343,7 @@ export const executeUpdate = async ( id, type, updated_at: time, + ...(updatedBy && { updated_by: updatedBy }), version: encodeHitVersion(indexDocResponseBody), namespaces, ...(originId && { originId }), diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/internal_utils.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/internal_utils.test.ts index bcc786aa8c340b..42cb6137095bae 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/internal_utils.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/internal_utils.test.ts @@ -102,6 +102,8 @@ describe('#getSavedObjectFromSource', () => { const updated_at = 'updatedAt'; // eslint-disable-next-line @typescript-eslint/naming-convention const created_by = 'createdBy'; + // eslint-disable-next-line @typescript-eslint/naming-convention + const updated_by = 'updatedBy'; const managed = false; function createRawDoc( @@ -123,6 +125,7 @@ describe('#getSavedObjectFromSource', () => { originId, updated_at, created_by, + updated_by, ...namespaceAttrs, }, }; @@ -145,6 +148,7 @@ describe('#getSavedObjectFromSource', () => { references, type, updated_at, + updated_by, created_by, version: encodeHitVersion(doc), }); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/internal_utils.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/internal_utils.ts index 379cd21337651b..da5142bdde465e 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/internal_utils.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/internal_utils.ts @@ -110,6 +110,7 @@ export function getSavedObjectFromSource( updated_at: updatedAt, created_at: createdAt, created_by: createdBy, + updated_by: updatedBy, coreMigrationVersion, typeMigrationVersion, managed, @@ -136,6 +137,7 @@ export function getSavedObjectFromSource( ...(updatedAt && { updated_at: updatedAt }), ...(createdAt && { created_at: createdAt }), ...(createdBy && { created_by: createdBy }), + ...(updatedBy && { updated_by: updatedBy }), version: encodeHitVersion(doc), attributes: doc._source[type], references: doc._source.references || [], diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.security_extension.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.security_extension.test.ts index 19062e0560243f..74d0e85785e129 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.security_extension.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.security_extension.test.ts @@ -302,6 +302,20 @@ describe('SavedObjectsRepository Security Extension', () => { }) ); }); + + test(`adds updated_by to the saved object when the current user is available`, async () => { + const profileUid = 'profileUid'; + mockSecurityExt.getCurrentUser.mockImplementationOnce(() => + mockAuthenticatedUser({ profile_uid: profileUid }) + ); + + const result = await updateSuccess(client, repository, registry, type, id, attributes, { + namespace, + }); + + expect(result).not.toHaveProperty('created_by'); + expect(result.updated_by).toBe(profileUid); + }); }); describe('#create', () => { @@ -425,7 +439,7 @@ describe('SavedObjectsRepository Security Extension', () => { ); }); - test(`adds created_by to the saved object when the current user is available`, async () => { + test(`adds created_by, updated_by to the saved object when the current user is available`, async () => { const profileUid = 'profileUid'; mockSecurityExt.getCurrentUser.mockImplementationOnce(() => mockAuthenticatedUser({ profile_uid: profileUid }) @@ -434,14 +448,16 @@ describe('SavedObjectsRepository Security Extension', () => { namespace, }); expect(response.created_by).toBe(profileUid); + expect(response.updated_by).toBe(profileUid); }); - test(`keeps created_by empty if the current user is not available`, async () => { + test(`keeps created_by, updated_by empty if the current user is not available`, async () => { mockSecurityExt.getCurrentUser.mockImplementationOnce(() => null); const response = await repository.create(MULTI_NAMESPACE_CUSTOM_INDEX_TYPE, attributes, { namespace, }); expect(response).not.toHaveProperty('created_by'); + expect(response).not.toHaveProperty('updated_by'); }); }); @@ -1345,7 +1361,7 @@ describe('SavedObjectsRepository Security Extension', () => { }); }); - test(`adds created_by to the saved object when the current user is available`, async () => { + test(`adds created_by, updated_by to the saved object when the current user is available`, async () => { const profileUid = 'profileUid'; mockSecurityExt.getCurrentUser.mockImplementationOnce(() => mockAuthenticatedUser({ profile_uid: profileUid }) @@ -1353,13 +1369,19 @@ describe('SavedObjectsRepository Security Extension', () => { const response = await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace }); expect(response.saved_objects[0].created_by).toBe(profileUid); expect(response.saved_objects[1].created_by).toBe(profileUid); + + expect(response.saved_objects[0].updated_by).toBe(profileUid); + expect(response.saved_objects[1].updated_by).toBe(profileUid); }); - test(`keeps created_by empty if the current user is not available`, async () => { + test(`keeps created_by, updated_by empty if the current user is not available`, async () => { mockSecurityExt.getCurrentUser.mockImplementationOnce(() => null); const response = await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace }); expect(response.saved_objects[0]).not.toHaveProperty('created_by'); expect(response.saved_objects[1]).not.toHaveProperty('created_by'); + + expect(response.saved_objects[0]).not.toHaveProperty('updated_by'); + expect(response.saved_objects[1]).not.toHaveProperty('updated_by'); }); }); @@ -1512,6 +1534,19 @@ describe('SavedObjectsRepository Security Extension', () => { expect(typeMap).toBe(authMap); }); }); + + test(`adds updated_by to the saved object when the current user is available`, async () => { + const profileUid = 'profileUid'; + mockSecurityExt.getCurrentUser.mockImplementationOnce(() => + mockAuthenticatedUser({ profile_uid: profileUid }) + ); + + const objects = [obj1, obj2]; + const result = await bulkUpdateSuccess(client, repository, registry, objects, { namespace }); + + expect(result.saved_objects[0].updated_by).toBe(profileUid); + expect(result.saved_objects[1].updated_by).toBe(profileUid); + }); }); describe('#bulkDelete', () => { diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/utils/included_fields.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/utils/included_fields.test.ts index b2bda0ec4007cb..81033c0b5d7b2e 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/utils/included_fields.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/utils/included_fields.test.ts @@ -22,6 +22,7 @@ describe('getRootFields', () => { "typeMigrationVersion", "managed", "updated_at", + "updated_by", "created_at", "created_by", "originId", diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/utils/included_fields.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/utils/included_fields.ts index c4d023d2b5fffd..fdfae557872008 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/utils/included_fields.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/utils/included_fields.ts @@ -16,6 +16,7 @@ const ROOT_FIELDS = [ 'typeMigrationVersion', 'managed', 'updated_at', + 'updated_by', 'created_at', 'created_by', 'originId', diff --git a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/serialization/serializer.test.ts b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/serialization/serializer.test.ts index 05fe2a12399761..2ab5be239b80d4 100644 --- a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/serialization/serializer.test.ts +++ b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/serialization/serializer.test.ts @@ -297,6 +297,18 @@ describe('#rawToSavedObject', () => { expect(actual).toHaveProperty('updated_at', now); }); + test('if specified it copies the _source.updated_by property to updated_by', () => { + const updatedBy = 'elastic'; + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + updated_by: updatedBy, + }, + }); + expect(actual).toHaveProperty('updated_by', updatedBy); + }); + test('if specified it copies the _source.created_at property to created_at', () => { const now = Date(); const actual = singleNamespaceSerializer.rawToSavedObject({ @@ -341,6 +353,16 @@ describe('#rawToSavedObject', () => { expect(actual).not.toHaveProperty('created_at'); }); + test(`if _source.updated_by is unspecified it doesn't set updated_by`, () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + }, + }); + expect(actual).not.toHaveProperty('updated_by'); + }); + test('if specified it copies the _source.originId property to originId', () => { const originId = 'baz'; const actual = singleNamespaceSerializer.rawToSavedObject({ diff --git a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/serialization/serializer.ts b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/serialization/serializer.ts index 7faffa75d61ed3..6448b87d392813 100644 --- a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/serialization/serializer.ts +++ b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/serialization/serializer.ts @@ -122,6 +122,7 @@ export class SavedObjectsSerializer implements ISavedObjectsSerializer { ...(coreMigrationVersion && { coreMigrationVersion }), ...(typeMigrationVersion != null ? { typeMigrationVersion } : {}), ...(_source.updated_at && { updated_at: _source.updated_at }), + ...(_source.updated_by && { updated_by: _source.updated_by }), ...(_source.created_at && { created_at: _source.created_at }), ...(_source.created_by && { created_by: _source.created_by }), ...(version && { version }), @@ -144,6 +145,7 @@ export class SavedObjectsSerializer implements ISavedObjectsSerializer { migrationVersion, // eslint-disable-next-line @typescript-eslint/naming-convention updated_at, + updated_by: updatedBy, created_at: createdAt, created_by: createdBy, version, @@ -164,6 +166,7 @@ export class SavedObjectsSerializer implements ISavedObjectsSerializer { ...(coreMigrationVersion && { coreMigrationVersion }), ...(typeMigrationVersion != null ? { typeMigrationVersion } : {}), ...(updated_at && { updated_at }), + ...(updatedBy && { updated_by: updatedBy }), ...(createdAt && { created_at: createdAt }), ...(createdBy && { created_by: createdBy }), }; diff --git a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/validation/schema.ts b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/validation/schema.ts index 8a23defbbf2672..952aff1d221ce5 100644 --- a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/validation/schema.ts +++ b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/validation/schema.ts @@ -36,6 +36,7 @@ const baseSchema = schema.object({ coreMigrationVersion: schema.maybe(schema.string()), typeMigrationVersion: schema.maybe(schema.string()), updated_at: schema.maybe(schema.string()), + updated_by: schema.maybe(schema.string()), created_at: schema.maybe(schema.string()), created_by: schema.maybe(schema.string()), version: schema.maybe(schema.string()), diff --git a/packages/core/saved-objects/core-saved-objects-browser-internal/src/simple_saved_object.ts b/packages/core/saved-objects/core-saved-objects-browser-internal/src/simple_saved_object.ts index af830b2d6b68b9..60d108c79981b5 100644 --- a/packages/core/saved-objects/core-saved-objects-browser-internal/src/simple_saved_object.ts +++ b/packages/core/saved-objects/core-saved-objects-browser-internal/src/simple_saved_object.ts @@ -33,6 +33,7 @@ export class SimpleSavedObjectImpl implements SimpleSavedObject public error: SavedObjectType['error']; public references: SavedObjectType['references']; public updatedAt: SavedObjectType['updated_at']; + public updatedBy: SavedObjectType['updated_by']; public createdAt: SavedObjectType['created_at']; public createdBy: SavedObjectType['created_by']; public namespaces: SavedObjectType['namespaces']; @@ -52,6 +53,7 @@ export class SimpleSavedObjectImpl implements SimpleSavedObject managed, namespaces, updated_at: updatedAt, + updated_by: updatedBy, created_at: createdAt, created_by: createdBy, }: SavedObjectType @@ -69,6 +71,7 @@ export class SimpleSavedObjectImpl implements SimpleSavedObject this.updatedAt = updatedAt; this.createdAt = createdAt; this.createdBy = createdBy; + this.updatedBy = updatedBy; if (error) { this.error = error; } diff --git a/packages/core/saved-objects/core-saved-objects-browser-mocks/src/simple_saved_object.mock.ts b/packages/core/saved-objects/core-saved-objects-browser-mocks/src/simple_saved_object.mock.ts index 288838b54ffcd0..0ea364a925da77 100644 --- a/packages/core/saved-objects/core-saved-objects-browser-mocks/src/simple_saved_object.mock.ts +++ b/packages/core/saved-objects/core-saved-objects-browser-mocks/src/simple_saved_object.mock.ts @@ -45,6 +45,7 @@ const createSimpleSavedObjectMock = ( error: savedObject.error, references: savedObject.references, updatedAt: savedObject.updated_at, + updatedBy: savedObject.updated_by, createdAt: savedObject.created_at, createdBy: savedObject.created_by, namespaces: savedObject.namespaces, diff --git a/packages/core/saved-objects/core-saved-objects-common/src/server_types.ts b/packages/core/saved-objects/core-saved-objects-common/src/server_types.ts index f5a69f8a2ee869..09cb65c2f4d236 100644 --- a/packages/core/saved-objects/core-saved-objects-common/src/server_types.ts +++ b/packages/core/saved-objects/core-saved-objects-common/src/server_types.ts @@ -76,6 +76,8 @@ export interface SavedObject { created_by?: string; /** Timestamp of the last time this document had been updated. */ updated_at?: string; + /** The ID of the user who last updated this object. */ + updated_by?: string; /** Error associated with this object, populated if an operation failed for this object. */ error?: SavedObjectError; /** The data for a Saved Object is stored as an object in the `attributes` property. **/ diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/__snapshots__/kibana_migrator.test.ts.snap b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/__snapshots__/kibana_migrator.test.ts.snap index 2c25694a8c2701..402826137b3593 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/__snapshots__/kibana_migrator.test.ts.snap +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/__snapshots__/kibana_migrator.test.ts.snap @@ -62,6 +62,9 @@ Object { "updated_at": Object { "type": "date", }, + "updated_by": Object { + "type": "keyword", + }, }, } `; diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/__snapshots__/build_active_mappings.test.ts.snap b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/__snapshots__/build_active_mappings.test.ts.snap index 13c6531ca969c3..614f766133e196 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/__snapshots__/build_active_mappings.test.ts.snap +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/__snapshots__/build_active_mappings.test.ts.snap @@ -54,6 +54,9 @@ Object { "updated_at": Object { "type": "date", }, + "updated_by": Object { + "type": "keyword", + }, }, } `; @@ -129,6 +132,9 @@ Object { "updated_at": Object { "type": "date", }, + "updated_by": Object { + "type": "keyword", + }, }, } `; diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/build_active_mappings.test.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/build_active_mappings.test.ts index ea4ff84564006a..498ec204531384 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/build_active_mappings.test.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/build_active_mappings.test.ts @@ -99,6 +99,9 @@ describe('getBaseMappings', () => { updated_at: { type: 'date', }, + updated_by: { + type: 'keyword', + }, created_at: { type: 'date', }, diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/build_active_mappings.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/build_active_mappings.ts index f5744669336b4b..5525814c072389 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/build_active_mappings.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/build_active_mappings.ts @@ -62,6 +62,9 @@ export function getBaseMappings(): IndexMapping { updated_at: { type: 'date', }, + updated_by: { + type: 'keyword', + }, created_at: { type: 'date', }, diff --git a/packages/core/saved-objects/core-saved-objects-server/src/serialization.ts b/packages/core/saved-objects/core-saved-objects-server/src/serialization.ts index 514956e8dd7dc2..fd61186128000a 100644 --- a/packages/core/saved-objects/core-saved-objects-server/src/serialization.ts +++ b/packages/core/saved-objects/core-saved-objects-server/src/serialization.ts @@ -111,6 +111,7 @@ export interface SavedObjectDoc { typeMigrationVersion?: string; version?: string; updated_at?: string; + updated_by?: string; created_at?: string; created_by?: string; originId?: string; diff --git a/packages/kbn-content-management-utils/src/saved_object_content_storage.ts b/packages/kbn-content-management-utils/src/saved_object_content_storage.ts index 36d9979cfdc8ab..25540c96e50c04 100644 --- a/packages/kbn-content-management-utils/src/saved_object_content_storage.ts +++ b/packages/kbn-content-management-utils/src/saved_object_content_storage.ts @@ -64,6 +64,7 @@ function savedObjectToItem( id, type, updated_at: updatedAt, + updated_by: updatedBy, created_at: createdAt, created_by: createdBy, attributes, @@ -78,6 +79,7 @@ function savedObjectToItem( id, type, managed, + updatedBy, updatedAt, createdAt, createdBy, diff --git a/packages/kbn-content-management-utils/src/types.ts b/packages/kbn-content-management-utils/src/types.ts index 6417d9af1e8881..9c9da41f6e00aa 100644 --- a/packages/kbn-content-management-utils/src/types.ts +++ b/packages/kbn-content-management-utils/src/types.ts @@ -202,6 +202,7 @@ export interface SOWithMetadata { createdAt?: string; updatedAt?: string; createdBy?: string; + updatedBy?: string; error?: { error: string; message: string; diff --git a/x-pack/test/api_integration/apis/content_management/created_by.ts b/x-pack/test/api_integration/apis/content_management/created_by.ts index 0dfe029bea8691..0deb94f40dd3cc 100644 --- a/x-pack/test/api_integration/apis/content_management/created_by.ts +++ b/x-pack/test/api_integration/apis/content_management/created_by.ts @@ -12,6 +12,7 @@ import { setupInteractiveUser, sampleDashboard, cleanupInteractiveUser, + LoginAsInteractiveUserResponse, } from './helpers'; export default function ({ getService }: FtrProviderContext) { @@ -32,11 +33,11 @@ export default function ({ getService }: FtrProviderContext) { describe('for interactive user', function () { const supertest = getService('supertestWithoutAuth'); - let sessionHeaders: { [key: string]: string } = {}; + let interactiveUser: LoginAsInteractiveUserResponse; before(async () => { await setupInteractiveUser({ getService }); - sessionHeaders = await loginAsInteractiveUser({ getService }); + interactiveUser = await loginAsInteractiveUser({ getService }); }); after(async () => { @@ -46,13 +47,14 @@ export default function ({ getService }: FtrProviderContext) { it('created_by is with profile_id', async () => { const createResponse = await supertest .post('/api/content_management/rpc/create') - .set(sessionHeaders) + .set(interactiveUser.headers) .set('kbn-xsrf', 'true') .send(sampleDashboard); expect(createResponse.status).to.be(200); expect(createResponse.body.result.result.item).to.be.ok(); expect(createResponse.body.result.result.item).to.have.key('createdBy'); + expect(createResponse.body.result.result.item.createdBy).to.be(interactiveUser.uid); }); }); }); diff --git a/x-pack/test/api_integration/apis/content_management/helpers.ts b/x-pack/test/api_integration/apis/content_management/helpers.ts index 81af941abfc6d8..856fe98b108c3f 100644 --- a/x-pack/test/api_integration/apis/content_management/helpers.ts +++ b/x-pack/test/api_integration/apis/content_management/helpers.ts @@ -21,10 +21,11 @@ export const sampleDashboard = { version: 2, }; -const usernameOrRole = 'content_manager_dashboard'; +const role = 'content_manager_dashboard'; +const users = ['content_manager_dashboard_1', 'content_manager_dashboard_2'] as const; export async function setupInteractiveUser({ getService }: Pick) { const security = getService('security'); - await security.role.create(usernameOrRole, { + await security.role.create(role, { elasticsearch: { cluster: [], indices: [], run_as: [] }, kibana: [ { @@ -35,27 +36,38 @@ export async function setupInteractiveUser({ getService }: Pick) { const security = getService('security'); - await security.user.delete(usernameOrRole); - await security.role.delete(usernameOrRole); + for (const user of users) { + await security.user.delete(user); + } + await security.role.delete(role); } +export interface LoginAsInteractiveUserResponse { + headers: { + Cookie: string; + }; + uid: string; +} export async function loginAsInteractiveUser({ getService, -}: Pick): Promise<{ - Cookie: string; -}> { + username = users[0], +}: Pick & { + username?: typeof users[number]; +}): Promise { const supertest = getService('supertestWithoutAuth'); const response = await supertest @@ -65,10 +77,15 @@ export async function loginAsInteractiveUser({ providerType: 'basic', providerName: 'basic', currentURL: '/', - params: { username: usernameOrRole, password: usernameOrRole }, + params: { username, password: username }, }) .expect(200); const cookie = parseCookie(response.header['set-cookie'][0])!.cookieString(); - return { Cookie: cookie }; + const { body: userWithProfileId } = await supertest + .get('/internal/security/me') + .set('Cookie', cookie) + .expect(200); + + return { headers: { Cookie: cookie }, uid: userWithProfileId.profile_uid }; } diff --git a/x-pack/test/api_integration/apis/content_management/index.ts b/x-pack/test/api_integration/apis/content_management/index.ts index 01967e73fae8d2..50f4f798e841a6 100644 --- a/x-pack/test/api_integration/apis/content_management/index.ts +++ b/x-pack/test/api_integration/apis/content_management/index.ts @@ -10,5 +10,6 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile, getService }: FtrProviderContext) { describe('content management', function () { loadTestFile(require.resolve('./created_by')); + loadTestFile(require.resolve('./updated_by')); }); } diff --git a/x-pack/test/api_integration/apis/content_management/updated_by.ts b/x-pack/test/api_integration/apis/content_management/updated_by.ts new file mode 100644 index 00000000000000..60821273b988a7 --- /dev/null +++ b/x-pack/test/api_integration/apis/content_management/updated_by.ts @@ -0,0 +1,185 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { + loginAsInteractiveUser, + setupInteractiveUser, + sampleDashboard, + cleanupInteractiveUser, + LoginAsInteractiveUserResponse, +} from './helpers'; + +export default function ({ getService }: FtrProviderContext) { + describe('updated_by', function () { + describe('for not interactive user', function () { + const supertest = getService('supertest'); + it('updated_by is empty', async () => { + const createResponse = await supertest + .post('/api/content_management/rpc/create') + .set('kbn-xsrf', 'true') + .send(sampleDashboard); + + expect(createResponse.status).to.be(200); + expect(createResponse.body.result.result.item).to.be.ok(); + expect(createResponse.body.result.result.item).to.not.have.key('updatedBy'); + + const updateResponse = await supertest + .post('/api/content_management/rpc/update') + .set('kbn-xsrf', 'true') + .send({ + contentTypeId: sampleDashboard.contentTypeId, + version: sampleDashboard.version, + options: { + references: [], + mergeAttributes: false, + }, + id: createResponse.body.result.result.item.id, + data: { + title: 'updated title', + }, + }); + + expect(updateResponse.status).to.be(200); + expect(updateResponse.body.result.result.item).to.be.ok(); + + const getResponse = await supertest + .post('/api/content_management/rpc/get') + .set('kbn-xsrf', 'true') + .send({ + id: createResponse.body.result.result.item.id, + contentTypeId: sampleDashboard.contentTypeId, + version: sampleDashboard.version, + }); + + expect(getResponse.status).to.be(200); + expect(getResponse.body.result.result.item).to.be.ok(); + expect(getResponse.body.result.result.item).to.not.have.key('updatedBy'); + }); + }); + + describe('for interactive user', function () { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const supertestWithAuth = getService('supertest'); + let interactiveUser: LoginAsInteractiveUserResponse; + let createResponse: any; + + before(async () => { + await setupInteractiveUser({ getService }); + interactiveUser = await loginAsInteractiveUser({ getService }); + }); + + beforeEach(async () => { + createResponse = await supertestWithoutAuth + .post('/api/content_management/rpc/create') + .set(interactiveUser.headers) + .set('kbn-xsrf', 'true') + .send(sampleDashboard); + }); + + after(async () => { + await cleanupInteractiveUser({ getService }); + }); + + it('updated_by is with profile_id', async () => { + expect(createResponse.status).to.be(200); + expect(createResponse.body.result.result.item).to.be.ok(); + expect(createResponse.body.result.result.item).to.have.key('updatedBy'); + expect(createResponse.body.result.result.item.updatedBy).to.be(interactiveUser.uid); + }); + + it('updated_by is empty after update with non interactive user', async () => { + const updateResponse = await supertestWithAuth + .post('/api/content_management/rpc/update') + .set('kbn-xsrf', 'true') + .send({ + contentTypeId: sampleDashboard.contentTypeId, + version: sampleDashboard.version, + options: { + references: [], + mergeAttributes: false, + }, + id: createResponse.body.result.result.item.id, + data: { + title: 'updated title', + }, + }); + + expect(updateResponse.status).to.be(200); + + const getResponse = await supertestWithAuth + .post('/api/content_management/rpc/get') + .set('kbn-xsrf', 'true') + .send({ + id: createResponse.body.result.result.item.id, + contentTypeId: sampleDashboard.contentTypeId, + version: sampleDashboard.version, + }); + + expect(getResponse.status).to.be(200); + expect(getResponse.body.result.result.item).to.be.ok(); + + const createdObject = createResponse.body.result.result.item; + const updatedObject = getResponse.body.result.result.item; + + expect(updatedObject).to.not.have.key('updatedBy'); + expect(updatedObject.createdBy).to.eql(createdObject.createdBy); + expect(updatedObject.createdAt).to.eql(createdObject.createdAt); + expect(updatedObject.updatedAt).to.be.greaterThan(createdObject.updatedAt); + }); + + it('updated_by is with profile_id of another user after update', async () => { + const interactiveUser2 = await loginAsInteractiveUser({ + getService, + username: 'content_manager_dashboard_2', + }); + + const updateResponse = await supertestWithoutAuth + .post('/api/content_management/rpc/update') + .set(interactiveUser2.headers) + .set('kbn-xsrf', 'true') + .send({ + contentTypeId: sampleDashboard.contentTypeId, + version: sampleDashboard.version, + options: { + references: [], + mergeAttributes: false, + }, + id: createResponse.body.result.result.item.id, + data: { + title: 'updated title', + }, + }); + + expect(updateResponse.status).to.be(200); + + const getResponse = await supertestWithAuth + .post('/api/content_management/rpc/get') + .set('kbn-xsrf', 'true') + .send({ + id: createResponse.body.result.result.item.id, + contentTypeId: sampleDashboard.contentTypeId, + version: sampleDashboard.version, + }); + + expect(getResponse.status).to.be(200); + expect(getResponse.body.result.result.item).to.be.ok(); + + const createdObject = createResponse.body.result.result.item; + const updatedObject = getResponse.body.result.result.item; + + expect(updatedObject).to.have.key('updatedBy'); + expect(updatedObject.updatedBy).to.not.eql(createdObject.updatedBy); + expect(updatedObject.createdBy).to.eql(interactiveUser.uid); + expect(updatedObject.updatedBy).to.eql(interactiveUser2.uid); + expect(updatedObject.createdAt).to.eql(createdObject.createdAt); + expect(updatedObject.updatedAt).to.be.greaterThan(createdObject.updatedAt); + }); + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/user_profiles/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/user_profiles/apis/bulk_create.ts new file mode 100644 index 00000000000000..b615df2b35bf23 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/user_profiles/apis/bulk_create.ts @@ -0,0 +1,43 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { loginAsInteractiveUser, LoginAsInteractiveUserResponse } from '../helpers'; +import { TEST_CASES } from '../../common/suites/create'; +import { AUTHENTICATION } from '../../common/lib/authentication'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + describe('bulk_create', function () { + let interactiveUser: LoginAsInteractiveUserResponse; + + before(async () => { + interactiveUser = await loginAsInteractiveUser({ + getService, + ...AUTHENTICATION.KIBANA_RBAC_USER, + }); + }); + + it('created_by/updated_by is with profile_id', async () => { + const soType = TEST_CASES.NEW_SINGLE_NAMESPACE_OBJ.type; + const createResponse = await supertest + .post(`/api/saved_objects/_bulk_create`) + .set(interactiveUser.headers) + .send([ + { type: soType, attributes: { title: 'test' } }, + { type: soType, attributes: { title: 'test' } }, + ]); + + expect(createResponse.status).to.be(200); + const [so1, so2] = createResponse.body.saved_objects; + expect(so1.created_by).to.be(interactiveUser.uid); + expect(so1.updated_by).to.be(interactiveUser.uid); + expect(so2.created_by).to.be(interactiveUser.uid); + expect(so2.updated_by).to.be(interactiveUser.uid); + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/user_profiles/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/user_profiles/apis/bulk_update.ts new file mode 100644 index 00000000000000..f7b5098d921d63 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/user_profiles/apis/bulk_update.ts @@ -0,0 +1,94 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { loginAsInteractiveUser } from '../helpers'; +import { TEST_CASES } from '../../common/suites/create'; +import { AUTHENTICATION } from '../../common/lib/authentication'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + describe('bulk_update', function () { + before(async () => { + await esArchiver.load( + 'x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces' + ); + }); + + after(async () => { + await esArchiver.unload( + 'x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces' + ); + }); + + it('updates updated_by with profile_id, created_by is untouched', async () => { + const { type, id } = TEST_CASES.SINGLE_NAMESPACE_DEFAULT_SPACE; + + // update with interactive user 1 + const interactiveUser1 = await loginAsInteractiveUser({ + getService, + ...AUTHENTICATION.KIBANA_RBAC_USER, + }); + const updateResponse1 = await supertest + .put(`/api/saved_objects/_bulk_update`) + .set(interactiveUser1.headers) + .send([{ id, type, attributes: { title: 'test' } }]); + + expect(updateResponse1.status).to.be(200); + expect(updateResponse1.body.saved_objects[0].updated_by).to.be(interactiveUser1.uid); + expect(updateResponse1.body.saved_objects[0].created_by).not.to.be.ok(); + + const getResponse1 = await supertest + .get(`/api/saved_objects/${type}/${id}`) + .set(interactiveUser1.headers); + + expect(getResponse1.body.updated_by).to.be(interactiveUser1.uid); + expect(getResponse1.body.created_by).not.to.be.ok(); + + // update with interactive user 2 + const interactiveUser2 = await loginAsInteractiveUser({ + getService, + ...AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + }); + + const updateResponse2 = await supertest + .put(`/api/saved_objects/_bulk_update`) + .set(interactiveUser2.headers) + .send([{ type, id, attributes: { title: 'test 2' } }]); + + expect(updateResponse2.status).to.be(200); + expect(updateResponse2.body.saved_objects[0].updated_by).to.be(interactiveUser2.uid); + expect(updateResponse2.body.saved_objects[0].created_by).not.to.be.ok(); + + const getResponse2 = await supertest + .get(`/api/saved_objects/${type}/${id}`) + .set(interactiveUser2.headers); + + expect(getResponse2.body.updated_by).to.be(interactiveUser2.uid); + expect(getResponse2.body.created_by).not.to.be.ok(); + + // update with "non-interactive" user, updated_by should become empty + const updateResponse3 = await supertest + .put(`/api/saved_objects/_bulk_update`) + .auth(AUTHENTICATION.KIBANA_RBAC_USER.username, AUTHENTICATION.KIBANA_RBAC_USER.password) + .send([{ type, id, attributes: { title: 'test 3' } }]); + + expect(updateResponse3.status).to.be(200); + expect(updateResponse3.body.saved_objects[0].updated_by).not.to.be.ok(); + expect(updateResponse3.body.saved_objects[0].created_by).not.to.be.ok(); + + const getResponse3 = await supertest + .get(`/api/saved_objects/${type}/${id}`) + .set(interactiveUser2.headers); + + expect(getResponse3.body.updated_by).not.to.be.ok(); + expect(getResponse3.body.created_by).not.to.be.ok(); + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/user_profiles/apis/create.ts b/x-pack/test/saved_object_api_integration/user_profiles/apis/create.ts new file mode 100644 index 00000000000000..d8dbdf054e51c7 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/user_profiles/apis/create.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { loginAsInteractiveUser, LoginAsInteractiveUserResponse } from '../helpers'; +import { TEST_CASES } from '../../common/suites/create'; +import { AUTHENTICATION } from '../../common/lib/authentication'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + describe('create', function () { + let interactiveUser: LoginAsInteractiveUserResponse; + + before(async () => { + interactiveUser = await loginAsInteractiveUser({ + getService, + ...AUTHENTICATION.KIBANA_RBAC_USER, + }); + }); + + it('created_by/updated_by is with profile_id', async () => { + const soType = TEST_CASES.NEW_SINGLE_NAMESPACE_OBJ.type; + const createResponse = await supertest + .post(`/api/saved_objects/${soType}`) + .set(interactiveUser.headers) + .send({ attributes: { title: 'test' } }); + + expect(createResponse.status).to.be(200); + const so = createResponse.body; + expect(so.created_by).to.be(interactiveUser.uid); + expect(so.updated_by).to.be(interactiveUser.uid); + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/user_profiles/apis/index.ts b/x-pack/test/saved_object_api_integration/user_profiles/apis/index.ts new file mode 100644 index 00000000000000..75c8c23a81aed6 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/user_profiles/apis/index.ts @@ -0,0 +1,25 @@ +/* + * 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 { FtrProviderContext } from '../../common/ftr_provider_context'; +import { createUsersAndRoles } from '../../common/lib/create_users_and_roles'; + +export default function ({ loadTestFile, getService }: FtrProviderContext) { + const es = getService('es'); + const supertest = getService('supertest'); + + describe('saved objects user profiles integration', function () { + before(async () => { + await createUsersAndRoles(es, supertest); + }); + + loadTestFile(require.resolve('./create')); + loadTestFile(require.resolve('./bulk_create')); + loadTestFile(require.resolve('./update')); + loadTestFile(require.resolve('./bulk_update')); + }); +} diff --git a/x-pack/test/saved_object_api_integration/user_profiles/apis/update.ts b/x-pack/test/saved_object_api_integration/user_profiles/apis/update.ts new file mode 100644 index 00000000000000..48f388003c4ad8 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/user_profiles/apis/update.ts @@ -0,0 +1,136 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { loginAsInteractiveUser } from '../helpers'; +import { TEST_CASES } from '../../common/suites/create'; +import { AUTHENTICATION } from '../../common/lib/authentication'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + describe('update', function () { + before(async () => { + await esArchiver.load( + 'x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces' + ); + }); + + after(async () => { + await esArchiver.unload( + 'x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces' + ); + }); + + it('updates updated_by with profile_id, created_by is untouched', async () => { + const { type, id } = TEST_CASES.SINGLE_NAMESPACE_DEFAULT_SPACE; + + // update with interactive user 1 + const interactiveUser1 = await loginAsInteractiveUser({ + getService, + ...AUTHENTICATION.KIBANA_RBAC_USER, + }); + const updateResponse1 = await supertest + .put(`/api/saved_objects/${type}/${id}`) + .set(interactiveUser1.headers) + .send({ attributes: { title: 'test' } }); + + expect(updateResponse1.status).to.be(200); + expect(updateResponse1.body.updated_by).to.be(interactiveUser1.uid); + expect(updateResponse1.body.created_by).not.to.be.ok(); + + const getResponse1 = await supertest + .get(`/api/saved_objects/${type}/${id}`) + .set(interactiveUser1.headers); + + expect(getResponse1.body.updated_by).to.be(interactiveUser1.uid); + expect(getResponse1.body.created_by).not.to.be.ok(); + + // update with interactive user 2 + const interactiveUser2 = await loginAsInteractiveUser({ + getService, + ...AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + }); + + const updateResponse2 = await supertest + .put(`/api/saved_objects/${type}/${id}`) + .set(interactiveUser2.headers) + .send({ attributes: { title: 'test 2' } }); + + expect(updateResponse2.status).to.be(200); + expect(updateResponse2.body.updated_by).to.be(interactiveUser2.uid); + expect(updateResponse2.body.created_by).not.to.be.ok(); + + const getResponse2 = await supertest + .get(`/api/saved_objects/${type}/${id}`) + .set(interactiveUser2.headers); + + expect(getResponse2.body.updated_by).to.be(interactiveUser2.uid); + expect(getResponse2.body.created_by).not.to.be.ok(); + + // update with "non-interactive" user, updated_by should become empty + const updateResponse3 = await supertest + .put(`/api/saved_objects/${type}/${id}`) + .auth(AUTHENTICATION.KIBANA_RBAC_USER.username, AUTHENTICATION.KIBANA_RBAC_USER.password) + .send({ attributes: { title: 'test 3' } }); + + expect(updateResponse3.status).to.be(200); + expect(updateResponse3.body.updated_by).not.to.be.ok(); + expect(updateResponse3.body.created_by).not.to.be.ok(); + + const getResponse3 = await supertest + .get(`/api/saved_objects/${type}/${id}`) + .set(interactiveUser2.headers); + + expect(getResponse3.body.updated_by).not.to.be.ok(); + expect(getResponse3.body.created_by).not.to.be.ok(); + }); + + it('upsert sets created_by and updated_by', async () => { + const { type } = TEST_CASES.SINGLE_NAMESPACE_DEFAULT_SPACE; + const id = `some-new-id-${Date.now()}`; + + // upsert with interactive user 1 + const interactiveUser1 = await loginAsInteractiveUser({ + getService, + ...AUTHENTICATION.KIBANA_RBAC_USER, + }); + const upsertResponse = await supertest + .put(`/api/saved_objects/${type}/${id}`) + .set(interactiveUser1.headers) + .send({ attributes: { title: 'updated' }, upsert: { title: 'upserted' } }); + + expect(upsertResponse.status).to.be(200); + expect(upsertResponse.body.attributes.title).to.be('upserted'); + expect(upsertResponse.body.updated_by).to.be(interactiveUser1.uid); + expect(upsertResponse.body.created_by).to.be(interactiveUser1.uid); + + // update with interactive user 2 + const interactiveUser2 = await loginAsInteractiveUser({ + getService, + ...AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + }); + + const updateResponse = await supertest + .put(`/api/saved_objects/${type}/${id}`) + .set(interactiveUser2.headers) + .send({ attributes: { title: 'updated' }, upsert: { title: 'upserted' } }); + + expect(updateResponse.status).to.be(200); + expect(updateResponse.body.attributes.title).to.be('updated'); + expect(updateResponse.body.updated_by).to.be(interactiveUser2.uid); + expect(updateResponse.body.created_by).not.to.be.ok(); + + const getResponse = await supertest + .get(`/api/saved_objects/${type}/${id}`) + .set(interactiveUser2.headers); + expect(getResponse.body.updated_by).to.be(interactiveUser2.uid); + expect(getResponse.body.created_by).to.be(interactiveUser1.uid); + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/user_profiles/config.ts b/x-pack/test/saved_object_api_integration/user_profiles/config.ts new file mode 100644 index 00000000000000..228d67d316059d --- /dev/null +++ b/x-pack/test/saved_object_api_integration/user_profiles/config.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 { createTestConfig } from '../common/config'; + +// eslint-disable-next-line import/no-default-export +export default createTestConfig('user_profiles', { license: 'basic' }); diff --git a/x-pack/test/saved_object_api_integration/user_profiles/helpers.ts b/x-pack/test/saved_object_api_integration/user_profiles/helpers.ts new file mode 100644 index 00000000000000..223ade8d7cc021 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/user_profiles/helpers.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { parse as parseCookie } from 'tough-cookie'; +import { FtrProviderContext } from '../common/ftr_provider_context'; + +export interface LoginAsInteractiveUserResponse { + headers: { + Cookie: string; + }; + uid: string; +} + +export async function loginAsInteractiveUser({ + getService, + username, + password, +}: Pick & { + username: string; + password: string; +}): Promise { + const supertest = getService('supertestWithoutAuth'); + + const response = await supertest + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { + username, + password, + }, + }) + .expect(200); + const cookie = parseCookie(response.header['set-cookie'][0])!.cookieString(); + + const { body: userWithProfileId } = await supertest + .get('/internal/security/me') + .set('Cookie', cookie) + .expect(200); + + return { headers: { Cookie: cookie }, uid: userWithProfileId.profile_uid }; +}