Add updated_by to saved objects (#182687)

## Summary

close https://github.com/elastic/kibana-team/issues/899

- Adds `updated_by` to saved object, similar to recently added
`created_by` https://github.com/elastic/kibana/pull/179344
- Fixes `created_by` / `created_at` should be set during upsert
- Improves functional tests coverage
This commit is contained in:
Anton Dosov 2024-05-29 17:03:11 +02:00 committed by GitHub
parent f7b9777f40
commit 88757a30a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 742 additions and 25 deletions

View file

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

View file

@ -42,6 +42,8 @@ export interface SimpleSavedObject<T = unknown> {
references: SavedObjectType<T>['references'];
/** The date this object was last updated */
updatedAt: SavedObjectType<T>['updated_at'];
/** The user that last updated this object */
updatedBy: SavedObjectType<T>['updated_by'];
/** The date this object was created */
createdAt: SavedObjectType<T>['created_at'];
/** The user that created this object */

View file

@ -85,6 +85,7 @@ export const performBulkCreate = async <T>(
} = options;
const time = getCurrentTime();
const createdBy = userHelper.getCurrentUserProfileUid();
const updatedBy = createdBy;
let preflightCheckIndexCounter = 0;
const expectedResults = objects.map<ExpectedResult>((object) => {
@ -234,6 +235,7 @@ export const performBulkCreate = async <T>(
updated_at: time,
created_at: time,
...(createdBy && { created_by: createdBy }),
...(updatedBy && { updated_by: updatedBy }),
references: object.references || [],
originId,
}) as SavedObjectSanitizedDoc<T>;

View file

@ -82,10 +82,12 @@ export const performBulkUpdate = async <T>(
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 <T>(
const documentToSave = {
[type]: attributes,
updated_at: time,
updated_by: updatedBy,
...(Array.isArray(references) && { references }),
};
@ -304,6 +307,7 @@ export const performBulkUpdate = async <T>(
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 <T>(
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 <T>(
...(namespaces && { namespaces }),
...(originId && { originId }),
updated_at,
updated_by,
version: encodeVersion(seqNo, primaryTerm),
attributes,
references,

View file

@ -71,6 +71,7 @@ export const performCreate = async <T>(
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 <T>(
created_at: time,
updated_at: time,
...(createdBy && { created_by: createdBy }),
...(updatedBy && { updated_by: updatedBy }),
...(Array.isArray(references) && { references }),
});

View file

@ -149,6 +149,7 @@ describe('find', () => {
'typeMigrationVersion',
'managed',
'updated_at',
'updated_by',
'created_at',
'created_by',
'originId',

View file

@ -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<SavedObjectsRawDocSource>).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<SavedObjectsRawDocSource>).body!

View file

@ -81,6 +81,7 @@ export const executeUpdate = async <T>(
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 <T>(
// END ALL PRE_CLIENT CALL CHECKS && MIGRATE EXISTING DOC;
const time = getCurrentTime();
const updatedBy = userHelper.getCurrentUserProfileUid();
let updatedOrCreatedSavedObject: SavedObject<T>;
// `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 <T>(
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<T>;
validationHelper.validateObjectForCreate(type, migratedUpsert);
@ -232,7 +236,9 @@ export const executeUpdate = async <T>(
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 <T>(
namespaces: savedObjectNamespaces,
attributes: updatedAttributes,
updated_at: time,
updated_by: updatedBy,
...(Array.isArray(references) && { references }),
});
@ -336,6 +343,7 @@ export const executeUpdate = async <T>(
id,
type,
updated_at: time,
...(updatedBy && { updated_by: updatedBy }),
version: encodeHitVersion(indexDocResponseBody),
namespaces,
...(originId && { originId }),

View file

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

View file

@ -110,6 +110,7 @@ export function getSavedObjectFromSource<T>(
updated_at: updatedAt,
created_at: createdAt,
created_by: createdBy,
updated_by: updatedBy,
coreMigrationVersion,
typeMigrationVersion,
managed,
@ -136,6 +137,7 @@ export function getSavedObjectFromSource<T>(
...(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 || [],

View file

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

View file

@ -22,6 +22,7 @@ describe('getRootFields', () => {
"typeMigrationVersion",
"managed",
"updated_at",
"updated_by",
"created_at",
"created_by",
"originId",

View file

@ -16,6 +16,7 @@ const ROOT_FIELDS = [
'typeMigrationVersion',
'managed',
'updated_at',
'updated_by',
'created_at',
'created_by',
'originId',

View file

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

View file

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

View file

@ -36,6 +36,7 @@ const baseSchema = schema.object<SavedObjectSanitizedDocSchema>({
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()),

View file

@ -33,6 +33,7 @@ export class SimpleSavedObjectImpl<T = unknown> implements SimpleSavedObject<T>
public error: SavedObjectType<T>['error'];
public references: SavedObjectType<T>['references'];
public updatedAt: SavedObjectType<T>['updated_at'];
public updatedBy: SavedObjectType<T>['updated_by'];
public createdAt: SavedObjectType<T>['created_at'];
public createdBy: SavedObjectType<T>['created_by'];
public namespaces: SavedObjectType<T>['namespaces'];
@ -52,6 +53,7 @@ export class SimpleSavedObjectImpl<T = unknown> implements SimpleSavedObject<T>
managed,
namespaces,
updated_at: updatedAt,
updated_by: updatedBy,
created_at: createdAt,
created_by: createdBy,
}: SavedObjectType<T>
@ -69,6 +71,7 @@ export class SimpleSavedObjectImpl<T = unknown> implements SimpleSavedObject<T>
this.updatedAt = updatedAt;
this.createdAt = createdAt;
this.createdBy = createdBy;
this.updatedBy = updatedBy;
if (error) {
this.error = error;
}

View file

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

View file

@ -76,6 +76,8 @@ export interface SavedObject<T = unknown> {
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. **/

View file

@ -62,6 +62,9 @@ Object {
"updated_at": Object {
"type": "date",
},
"updated_by": Object {
"type": "keyword",
},
},
}
`;

View file

@ -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",
},
},
}
`;

View file

@ -99,6 +99,9 @@ describe('getBaseMappings', () => {
updated_at: {
type: 'date',
},
updated_by: {
type: 'keyword',
},
created_at: {
type: 'date',
},

View file

@ -62,6 +62,9 @@ export function getBaseMappings(): IndexMapping {
updated_at: {
type: 'date',
},
updated_by: {
type: 'keyword',
},
created_at: {
type: 'date',
},

View file

@ -111,6 +111,7 @@ export interface SavedObjectDoc<T = unknown> {
typeMigrationVersion?: string;
version?: string;
updated_at?: string;
updated_by?: string;
created_at?: string;
created_by?: string;
originId?: string;

View file

@ -64,6 +64,7 @@ function savedObjectToItem<Attributes extends object>(
id,
type,
updated_at: updatedAt,
updated_by: updatedBy,
created_at: createdAt,
created_by: createdBy,
attributes,
@ -78,6 +79,7 @@ function savedObjectToItem<Attributes extends object>(
id,
type,
managed,
updatedBy,
updatedAt,
createdAt,
createdBy,

View file

@ -202,6 +202,7 @@ export interface SOWithMetadata<Attributes extends object = object> {
createdAt?: string;
updatedAt?: string;
createdBy?: string;
updatedBy?: string;
error?: {
error: string;
message: string;

View file

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

View file

@ -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<FtrProviderContext, 'getService'>) {
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<FtrProviderConte
],
});
await security.user.create(usernameOrRole, {
password: usernameOrRole,
roles: [usernameOrRole],
full_name: usernameOrRole.toUpperCase(),
email: `${usernameOrRole}@elastic.co`,
});
for (const user of users) {
await security.user.create(user, {
password: user,
roles: [role],
full_name: user.toUpperCase(),
email: `${user}@elastic.co`,
});
}
}
export async function cleanupInteractiveUser({
getService,
}: Pick<FtrProviderContext, 'getService'>) {
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<FtrProviderContext, 'getService'>): Promise<{
Cookie: string;
}> {
username = users[0],
}: Pick<FtrProviderContext, 'getService'> & {
username?: typeof users[number];
}): Promise<LoginAsInteractiveUserResponse> {
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 };
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<FtrProviderContext, 'getService'> & {
username: string;
password: string;
}): Promise<LoginAsInteractiveUserResponse> {
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 };
}