Change saved objects client find to allow partial authorization (#77699)

This commit is contained in:
Joe Portner 2020-09-22 12:40:38 -04:00 committed by GitHub
parent 7544a33901
commit d666038c8f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 1071 additions and 529 deletions

View file

@ -29,4 +29,5 @@ export interface SavedObjectsFindOptions
| [sortField](./kibana-plugin-core-public.savedobjectsfindoptions.sortfield.md) | <code>string</code> | |
| [sortOrder](./kibana-plugin-core-public.savedobjectsfindoptions.sortorder.md) | <code>string</code> | |
| [type](./kibana-plugin-core-public.savedobjectsfindoptions.type.md) | <code>string &#124; string[]</code> | |
| [typeToNamespacesMap](./kibana-plugin-core-public.savedobjectsfindoptions.typetonamespacesmap.md) | <code>Map&lt;string, string[] &#124; undefined&gt;</code> | This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved object client wrapper. If this is defined, it supersedes the <code>type</code> and <code>namespaces</code> fields when building the Elasticsearch query. Any types that are not included in this map will be excluded entirely. If a type is included but its value is undefined, the operation will search for that type in the Default namespace. |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [SavedObjectsFindOptions](./kibana-plugin-core-public.savedobjectsfindoptions.md) &gt; [typeToNamespacesMap](./kibana-plugin-core-public.savedobjectsfindoptions.typetonamespacesmap.md)
## SavedObjectsFindOptions.typeToNamespacesMap property
This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved object client wrapper. If this is defined, it supersedes the `type` and `namespaces` fields when building the Elasticsearch query. Any types that are not included in this map will be excluded entirely. If a type is included but its value is undefined, the operation will search for that type in the Default namespace.
<b>Signature:</b>
```typescript
typeToNamespacesMap?: Map<string, string[] | undefined>;
```

View file

@ -29,4 +29,5 @@ export interface SavedObjectsFindOptions
| [sortField](./kibana-plugin-core-server.savedobjectsfindoptions.sortfield.md) | <code>string</code> | |
| [sortOrder](./kibana-plugin-core-server.savedobjectsfindoptions.sortorder.md) | <code>string</code> | |
| [type](./kibana-plugin-core-server.savedobjectsfindoptions.type.md) | <code>string &#124; string[]</code> | |
| [typeToNamespacesMap](./kibana-plugin-core-server.savedobjectsfindoptions.typetonamespacesmap.md) | <code>Map&lt;string, string[] &#124; undefined&gt;</code> | This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved object client wrapper. If this is defined, it supersedes the <code>type</code> and <code>namespaces</code> fields when building the Elasticsearch query. Any types that are not included in this map will be excluded entirely. If a type is included but its value is undefined, the operation will search for that type in the Default namespace. |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) &gt; [typeToNamespacesMap](./kibana-plugin-core-server.savedobjectsfindoptions.typetonamespacesmap.md)
## SavedObjectsFindOptions.typeToNamespacesMap property
This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved object client wrapper. If this is defined, it supersedes the `type` and `namespaces` fields when building the Elasticsearch query. Any types that are not included in this map will be excluded entirely. If a type is included but its value is undefined, the operation will search for that type in the Default namespace.
<b>Signature:</b>
```typescript
typeToNamespacesMap?: Map<string, string[] | undefined>;
```

View file

@ -7,14 +7,14 @@
<b>Signature:</b>
```typescript
find<T = unknown>({ search, defaultSearchOperator, searchFields, rootSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, }: SavedObjectsFindOptions): Promise<SavedObjectsFindResponse<T>>;
find<T = unknown>(options: SavedObjectsFindOptions): Promise<SavedObjectsFindResponse<T>>;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| { search, defaultSearchOperator, searchFields, rootSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, } | <code>SavedObjectsFindOptions</code> | |
| options | <code>SavedObjectsFindOptions</code> | |
<b>Returns:</b>

View file

@ -24,7 +24,7 @@ export declare class SavedObjectsRepository
| [delete(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.delete.md) | | Deletes an object |
| [deleteByNamespace(namespace, options)](./kibana-plugin-core-server.savedobjectsrepository.deletebynamespace.md) | | Deletes all objects from the provided namespace. |
| [deleteFromNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md) | | Removes one or more namespaces from a given multi-namespace saved object. If no namespaces remain, the saved object is deleted entirely. This method and \[<code>addToNamespaces</code>\][SavedObjectsRepository.addToNamespaces()](./kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md) are the only ways to change which Spaces a multi-namespace saved object is shared to. |
| [find({ search, defaultSearchOperator, searchFields, rootSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, })](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | |
| [find(options)](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | |
| [get(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.get.md) | | Gets a single object |
| [incrementCounter(type, id, counterFieldName, options)](./kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md) | | Increases a counter field by one. Creates the document if one doesn't exist for the given id. |
| [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsrepository.update.md) | | Updates an object |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsUtils](./kibana-plugin-core-server.savedobjectsutils.md) &gt; [createEmptyFindResponse](./kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md)
## SavedObjectsUtils.createEmptyFindResponse property
Creates an empty response for a find operation. This is only intended to be used by saved objects client wrappers.
<b>Signature:</b>
```typescript
static createEmptyFindResponse: <T>({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse<T>;
```

View file

@ -15,6 +15,7 @@ export declare class SavedObjectsUtils
| Property | Modifiers | Type | Description |
| --- | --- | --- | --- |
| [createEmptyFindResponse](./kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md) | <code>static</code> | <code>&lt;T&gt;({ page, perPage, }: SavedObjectsFindOptions) =&gt; SavedObjectsFindResponse&lt;T&gt;</code> | Creates an empty response for a find operation. This is only intended to be used by saved objects client wrappers. |
| [namespaceIdToString](./kibana-plugin-core-server.savedobjectsutils.namespaceidtostring.md) | <code>static</code> | <code>(namespace?: string &#124; undefined) =&gt; string</code> | Converts a given saved object namespace ID to its string representation. All namespace IDs have an identical string representation, with the exception of the <code>undefined</code> namespace ID (which has a namespace string of <code>'default'</code>). |
| [namespaceStringToId](./kibana-plugin-core-server.savedobjectsutils.namespacestringtoid.md) | <code>static</code> | <code>(namespace: string) =&gt; string &#124; undefined</code> | Converts a given saved object namespace string to its ID representation. All namespace strings have an identical ID representation, with the exception of the <code>'default'</code> namespace string (which has a namespace ID of <code>undefined</code>). |

View file

@ -1079,6 +1079,7 @@ export interface SavedObjectsFindOptions {
sortOrder?: string;
// (undocumented)
type: string | string[];
typeToNamespacesMap?: Map<string, string[] | undefined>;
}
// @public

View file

@ -34,7 +34,7 @@ import { HttpFetchOptions, HttpSetup } from '../http';
type SavedObjectsFindOptions = Omit<
SavedObjectFindOptionsServer,
'namespace' | 'sortOrder' | 'rootSearchFields'
'sortOrder' | 'rootSearchFields' | 'typeToNamespacesMap'
>;
type PromiseType<T extends Promise<any>> = T extends Promise<infer U> ? U : never;

View file

@ -2477,6 +2477,33 @@ describe('SavedObjectsRepository', () => {
expect(client.search).not.toHaveBeenCalled();
});
it(`throws when namespaces is an empty array`, async () => {
await expect(
savedObjectsRepository.find({ type: 'foo', namespaces: [] })
).rejects.toThrowError('options.namespaces cannot be an empty array');
expect(client.search).not.toHaveBeenCalled();
});
it(`throws when type is not falsy and typeToNamespacesMap is defined`, async () => {
await expect(
savedObjectsRepository.find({ type: 'foo', typeToNamespacesMap: new Map() })
).rejects.toThrowError(
'options.type must be an empty string when options.typeToNamespacesMap is used'
);
expect(client.search).not.toHaveBeenCalled();
});
it(`throws when type is not an empty array and typeToNamespacesMap is defined`, async () => {
const test = async (args) => {
await expect(savedObjectsRepository.find(args)).rejects.toThrowError(
'options.namespaces must be an empty array when options.typeToNamespacesMap is used'
);
expect(client.search).not.toHaveBeenCalled();
};
await test({ type: '', typeToNamespacesMap: new Map() });
await test({ type: '', namespaces: ['some-ns'], typeToNamespacesMap: new Map() });
});
it(`throws when searchFields is defined but not an array`, async () => {
await expect(
savedObjectsRepository.find({ type, searchFields: 'string' })
@ -2493,7 +2520,7 @@ describe('SavedObjectsRepository', () => {
it(`throws when KQL filter syntax is invalid`, async () => {
const findOpts = {
namespace,
namespaces: [namespace],
search: 'foo*',
searchFields: ['foo'],
type: ['dashboard'],
@ -2577,38 +2604,70 @@ describe('SavedObjectsRepository', () => {
const test = async (types) => {
const result = await savedObjectsRepository.find({ type: types });
expect(result).toEqual(expect.objectContaining({ saved_objects: [] }));
expect(client.search).not.toHaveBeenCalled();
};
await test('unknownType');
await test(HIDDEN_TYPE);
await test(['unknownType', HIDDEN_TYPE]);
});
it(`should return empty results when attempting to find only invalid or hidden types using typeToNamespacesMap`, async () => {
const test = async (types) => {
const result = await savedObjectsRepository.find({
typeToNamespacesMap: new Map(types.map((x) => [x, undefined])),
type: '',
namespaces: [],
});
expect(result).toEqual(expect.objectContaining({ saved_objects: [] }));
expect(client.search).not.toHaveBeenCalled();
};
await test(['unknownType']);
await test([HIDDEN_TYPE]);
await test(['unknownType', HIDDEN_TYPE]);
});
});
describe('search dsl', () => {
it(`passes mappings, registry, search, defaultSearchOperator, searchFields, type, sortField, sortOrder and hasReference to getSearchDsl`, async () => {
const commonOptions = {
type: [type], // cannot be used when `typeToNamespacesMap` is present
namespaces: [namespace], // cannot be used when `typeToNamespacesMap` is present
search: 'foo*',
searchFields: ['foo'],
sortField: 'name',
sortOrder: 'desc',
defaultSearchOperator: 'AND',
hasReference: {
type: 'foo',
id: '1',
},
kueryNode: undefined,
};
it(`passes mappings, registry, and search options to getSearchDsl`, async () => {
await findSuccess(commonOptions, namespace);
expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, commonOptions);
});
it(`accepts typeToNamespacesMap`, async () => {
const relevantOpts = {
namespaces: [namespace],
search: 'foo*',
searchFields: ['foo'],
type: [type],
sortField: 'name',
sortOrder: 'desc',
defaultSearchOperator: 'AND',
hasReference: {
type: 'foo',
id: '1',
},
kueryNode: undefined,
...commonOptions,
type: '',
namespaces: [],
typeToNamespacesMap: new Map([[type, [namespace]]]), // can only be used when `type` is falsy and `namespaces` is an empty array
};
await findSuccess(relevantOpts, namespace);
expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, relevantOpts);
expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, {
...relevantOpts,
type: [type],
});
});
it(`accepts KQL expression filter and passes KueryNode to getSearchDsl`, async () => {
const findOpts = {
namespace,
namespaces: [namespace],
search: 'foo*',
searchFields: ['foo'],
type: ['dashboard'],
@ -2649,7 +2708,7 @@ describe('SavedObjectsRepository', () => {
it(`accepts KQL KueryNode filter and passes KueryNode to getSearchDsl`, async () => {
const findOpts = {
namespace,
namespaces: [namespace],
search: 'foo*',
searchFields: ['foo'],
type: ['dashboard'],

View file

@ -67,7 +67,7 @@ import {
} from '../../types';
import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry';
import { validateConvertFilterToKueryNode } from './filter_utils';
import { SavedObjectsUtils } from './utils';
import { FIND_DEFAULT_PAGE, FIND_DEFAULT_PER_PAGE, SavedObjectsUtils } from './utils';
// BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository
// so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient.
@ -693,37 +693,51 @@ export class SavedObjectsRepository {
* @property {string} [options.preference]
* @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page }
*/
async find<T = unknown>({
search,
defaultSearchOperator = 'OR',
searchFields,
rootSearchFields,
hasReference,
page = 1,
perPage = 20,
sortField,
sortOrder,
fields,
namespaces,
type,
filter,
preference,
}: SavedObjectsFindOptions): Promise<SavedObjectsFindResponse<T>> {
if (!type) {
async find<T = unknown>(options: SavedObjectsFindOptions): Promise<SavedObjectsFindResponse<T>> {
const {
search,
defaultSearchOperator = 'OR',
searchFields,
rootSearchFields,
hasReference,
page = FIND_DEFAULT_PAGE,
perPage = FIND_DEFAULT_PER_PAGE,
sortField,
sortOrder,
fields,
namespaces,
type,
typeToNamespacesMap,
filter,
preference,
} = options;
if (!type && !typeToNamespacesMap) {
throw SavedObjectsErrorHelpers.createBadRequestError(
'options.type must be a string or an array of strings'
);
} else if (namespaces?.length === 0 && !typeToNamespacesMap) {
throw SavedObjectsErrorHelpers.createBadRequestError(
'options.namespaces cannot be an empty array'
);
} else if (type && typeToNamespacesMap) {
throw SavedObjectsErrorHelpers.createBadRequestError(
'options.type must be an empty string when options.typeToNamespacesMap is used'
);
} else if ((!namespaces || namespaces?.length) && typeToNamespacesMap) {
throw SavedObjectsErrorHelpers.createBadRequestError(
'options.namespaces must be an empty array when options.typeToNamespacesMap is used'
);
}
const types = Array.isArray(type) ? type : [type];
const types = type
? Array.isArray(type)
? type
: [type]
: Array.from(typeToNamespacesMap!.keys());
const allowedTypes = types.filter((t) => this._allowedTypes.includes(t));
if (allowedTypes.length === 0) {
return {
page,
per_page: perPage,
total: 0,
saved_objects: [],
};
return SavedObjectsUtils.createEmptyFindResponse<T>(options);
}
if (searchFields && !Array.isArray(searchFields)) {
@ -766,6 +780,7 @@ export class SavedObjectsRepository {
sortField,
sortOrder,
namespaces,
typeToNamespacesMap,
hasReference,
kueryNode,
}),

View file

@ -50,6 +50,40 @@ const ALL_TYPE_SUBSETS = ALL_TYPES.reduce(
.filter((x) => x.length) // exclude empty set
.map((x) => (x.length === 1 ? x[0] : x)); // if a subset is a single string, destructure it
const createTypeClause = (type: string, namespaces?: string[]) => {
if (registry.isMultiNamespace(type)) {
return {
bool: {
must: expect.arrayContaining([{ terms: { namespaces: namespaces ?? ['default'] } }]),
must_not: [{ exists: { field: 'namespace' } }],
},
};
} else if (registry.isSingleNamespace(type)) {
const nonDefaultNamespaces = namespaces?.filter((n) => n !== 'default') ?? [];
const should: any = [];
if (nonDefaultNamespaces.length > 0) {
should.push({ terms: { namespace: nonDefaultNamespaces } });
}
if (namespaces?.includes('default')) {
should.push({ bool: { must_not: [{ exists: { field: 'namespace' } }] } });
}
return {
bool: {
must: [{ term: { type } }],
should: expect.arrayContaining(should),
minimum_should_match: 1,
must_not: [{ exists: { field: 'namespaces' } }],
},
};
}
// isNamespaceAgnostic
return {
bool: expect.objectContaining({
must_not: [{ exists: { field: 'namespace' } }, { exists: { field: 'namespaces' } }],
}),
};
};
/**
* Note: these tests cases are defined in the order they appear in the source code, for readability's sake
*/
@ -198,40 +232,6 @@ describe('#getQueryParams', () => {
});
describe('`namespaces` parameter', () => {
const createTypeClause = (type: string, namespaces?: string[]) => {
if (registry.isMultiNamespace(type)) {
return {
bool: {
must: expect.arrayContaining([{ terms: { namespaces: namespaces ?? ['default'] } }]),
must_not: [{ exists: { field: 'namespace' } }],
},
};
} else if (registry.isSingleNamespace(type)) {
const nonDefaultNamespaces = namespaces?.filter((n) => n !== 'default') ?? [];
const should: any = [];
if (nonDefaultNamespaces.length > 0) {
should.push({ terms: { namespace: nonDefaultNamespaces } });
}
if (namespaces?.includes('default')) {
should.push({ bool: { must_not: [{ exists: { field: 'namespace' } }] } });
}
return {
bool: {
must: [{ term: { type } }],
should: expect.arrayContaining(should),
minimum_should_match: 1,
must_not: [{ exists: { field: 'namespaces' } }],
},
};
}
// isNamespaceAgnostic
return {
bool: expect.objectContaining({
must_not: [{ exists: { field: 'namespace' } }, { exists: { field: 'namespaces' } }],
}),
};
};
const expectResult = (result: Result, ...typeClauses: any) => {
expect(result.query.bool.filter).toEqual(
expect.arrayContaining([
@ -281,6 +281,37 @@ describe('#getQueryParams', () => {
test(['default']);
});
});
describe('`typeToNamespacesMap` parameter', () => {
const expectResult = (result: Result, ...typeClauses: any) => {
expect(result.query.bool.filter).toEqual(
expect.arrayContaining([
{ bool: expect.objectContaining({ should: typeClauses, minimum_should_match: 1 }) },
])
);
};
it('supersedes `type` and `namespaces` parameters', () => {
const result = getQueryParams({
mappings,
registry,
type: ['pending', 'saved', 'shared', 'global'],
namespaces: ['foo', 'bar', 'default'],
typeToNamespacesMap: new Map([
['pending', ['foo']], // 'pending' is only authorized in the 'foo' namespace
// 'saved' is not authorized in any namespaces
['shared', ['bar', 'default']], // 'shared' is only authorized in the 'bar' and 'default' namespaces
['global', ['foo', 'bar', 'default']], // 'global' is authorized in all namespaces (which are ignored anyway)
]),
});
expectResult(
result,
createTypeClause('pending', ['foo']),
createTypeClause('shared', ['bar', 'default']),
createTypeClause('global')
);
});
});
});
describe('search clause (query.bool.must.simple_query_string)', () => {

View file

@ -129,6 +129,7 @@ interface QueryParams {
registry: ISavedObjectTypeRegistry;
namespaces?: string[];
type?: string | string[];
typeToNamespacesMap?: Map<string, string[] | undefined>;
search?: string;
searchFields?: string[];
rootSearchFields?: string[];
@ -145,6 +146,7 @@ export function getQueryParams({
registry,
namespaces,
type,
typeToNamespacesMap,
search,
searchFields,
rootSearchFields,
@ -152,7 +154,10 @@ export function getQueryParams({
hasReference,
kueryNode,
}: QueryParams) {
const types = getTypes(mappings, type);
const types = getTypes(
mappings,
typeToNamespacesMap ? Array.from(typeToNamespacesMap.keys()) : type
);
// A de-duplicated set of namespaces makes for a more effecient query.
//
@ -163,9 +168,12 @@ export function getQueryParams({
// since that is consistent with how a single-namespace search behaves in the OSS distribution. Leaving the wildcard in place
// would result in no results being returned, as the wildcard is treated as a literal, and not _actually_ as a wildcard.
// We had a good discussion around the tradeoffs here: https://github.com/elastic/kibana/pull/67644#discussion_r441055716
const normalizedNamespaces = namespaces
? Array.from(new Set(namespaces.map((x) => (x === '*' ? DEFAULT_NAMESPACE_STRING : x))))
: undefined;
const normalizeNamespaces = (namespacesToNormalize?: string[]) =>
namespacesToNormalize
? Array.from(
new Set(namespacesToNormalize.map((x) => (x === '*' ? DEFAULT_NAMESPACE_STRING : x)))
)
: undefined;
const bool: any = {
filter: [
@ -197,9 +205,12 @@ export function getQueryParams({
},
]
: undefined,
should: types.map((shouldType) =>
getClauseForType(registry, normalizedNamespaces, shouldType)
),
should: types.map((shouldType) => {
const normalizedNamespaces = normalizeNamespaces(
typeToNamespacesMap ? typeToNamespacesMap.get(shouldType) : namespaces
);
return getClauseForType(registry, normalizedNamespaces, shouldType);
}),
minimum_should_match: 1,
},
},

View file

@ -57,10 +57,11 @@ describe('getSearchDsl', () => {
});
describe('passes control', () => {
it('passes (mappings, schema, namespaces, type, search, searchFields, rootSearchFields, hasReference) to getQueryParams', () => {
it('passes (mappings, schema, namespaces, type, typeToNamespacesMap, search, searchFields, rootSearchFields, hasReference) to getQueryParams', () => {
const opts = {
namespaces: ['foo-namespace'],
type: 'foo',
typeToNamespacesMap: new Map(),
search: 'bar',
searchFields: ['baz'],
rootSearchFields: ['qux'],
@ -78,6 +79,7 @@ describe('getSearchDsl', () => {
registry,
namespaces: opts.namespaces,
type: opts.type,
typeToNamespacesMap: opts.typeToNamespacesMap,
search: opts.search,
searchFields: opts.searchFields,
rootSearchFields: opts.rootSearchFields,

View file

@ -35,6 +35,7 @@ interface GetSearchDslOptions {
sortField?: string;
sortOrder?: string;
namespaces?: string[];
typeToNamespacesMap?: Map<string, string[] | undefined>;
hasReference?: {
type: string;
id: string;
@ -56,6 +57,7 @@ export function getSearchDsl(
sortField,
sortOrder,
namespaces,
typeToNamespacesMap,
hasReference,
kueryNode,
} = options;
@ -74,6 +76,7 @@ export function getSearchDsl(
registry,
namespaces,
type,
typeToNamespacesMap,
search,
searchFields,
rootSearchFields,

View file

@ -17,10 +17,11 @@
* under the License.
*/
import { SavedObjectsFindOptions } from '../../types';
import { SavedObjectsUtils } from './utils';
describe('SavedObjectsUtils', () => {
const { namespaceIdToString, namespaceStringToId } = SavedObjectsUtils;
const { namespaceIdToString, namespaceStringToId, createEmptyFindResponse } = SavedObjectsUtils;
describe('#namespaceIdToString', () => {
it('converts `undefined` to default namespace string', () => {
@ -54,4 +55,26 @@ describe('SavedObjectsUtils', () => {
test('');
});
});
describe('#createEmptyFindResponse', () => {
it('returns expected result', () => {
const options = {} as SavedObjectsFindOptions;
expect(createEmptyFindResponse(options)).toEqual({
page: 1,
per_page: 20,
total: 0,
saved_objects: [],
});
});
it('handles `page` field', () => {
const options = { page: 42 } as SavedObjectsFindOptions;
expect(createEmptyFindResponse(options).page).toEqual(42);
});
it('handles `perPage` field', () => {
const options = { perPage: 42 } as SavedObjectsFindOptions;
expect(createEmptyFindResponse(options).per_page).toEqual(42);
});
});
});

View file

@ -17,7 +17,12 @@
* under the License.
*/
import { SavedObjectsFindOptions } from '../../types';
import { SavedObjectsFindResponse } from '..';
export const DEFAULT_NAMESPACE_STRING = 'default';
export const FIND_DEFAULT_PAGE = 1;
export const FIND_DEFAULT_PER_PAGE = 20;
/**
* @public
@ -50,4 +55,17 @@ export class SavedObjectsUtils {
return namespace !== DEFAULT_NAMESPACE_STRING ? namespace : undefined;
};
/**
* Creates an empty response for a find operation. This is only intended to be used by saved objects client wrappers.
*/
public static createEmptyFindResponse = <T>({
page = FIND_DEFAULT_PAGE,
perPage = FIND_DEFAULT_PER_PAGE,
}: SavedObjectsFindOptions): SavedObjectsFindResponse<T> => ({
page,
per_page: perPage,
total: 0,
saved_objects: [],
});
}

View file

@ -89,6 +89,14 @@ export interface SavedObjectsFindOptions {
defaultSearchOperator?: 'AND' | 'OR';
filter?: string | KueryNode;
namespaces?: string[];
/**
* This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved
* object client wrapper.
* If this is defined, it supersedes the `type` and `namespaces` fields when building the Elasticsearch query.
* Any types that are not included in this map will be excluded entirely.
* If a type is included but its value is undefined, the operation will search for that type in the Default namespace.
*/
typeToNamespacesMap?: Map<string, string[] | undefined>;
/** An optional ES preference value to be used for the query **/
preference?: string;
}

View file

@ -2177,6 +2177,7 @@ export interface SavedObjectsFindOptions {
sortOrder?: string;
// (undocumented)
type: string | string[];
typeToNamespacesMap?: Map<string, string[] | undefined>;
}
// @public
@ -2388,7 +2389,7 @@ export class SavedObjectsRepository {
deleteByNamespace(namespace: string, options?: SavedObjectsDeleteByNamespaceOptions): Promise<any>;
deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise<SavedObjectsDeleteFromNamespacesResponse>;
// (undocumented)
find<T = unknown>({ search, defaultSearchOperator, searchFields, rootSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, }: SavedObjectsFindOptions): Promise<SavedObjectsFindResponse<T>>;
find<T = unknown>(options: SavedObjectsFindOptions): Promise<SavedObjectsFindResponse<T>>;
get<T = unknown>(type: string, id: string, options?: SavedObjectsBaseOptions): Promise<SavedObject<T>>;
incrementCounter(type: string, id: string, counterFieldName: string, options?: SavedObjectsIncrementCounterOptions): Promise<SavedObject>;
update<T = unknown>(type: string, id: string, attributes: Partial<T>, options?: SavedObjectsUpdateOptions): Promise<SavedObjectsUpdateResponse<T>>;
@ -2496,6 +2497,7 @@ export interface SavedObjectsUpdateResponse<T = unknown> extends Omit<SavedObjec
// @public (undocumented)
export class SavedObjectsUtils {
static createEmptyFindResponse: <T>({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse<T>;
static namespaceIdToString: (namespace?: string | undefined) => string;
static namespaceStringToId: (namespace: string) => string | undefined;
}

View file

@ -609,22 +609,83 @@ describe('#find', () => {
await expectGeneralError(client.find, { type: type1 });
});
test(`throws decorated ForbiddenError when type's singular and unauthorized`, async () => {
test(`returns empty result when unauthorized`, async () => {
clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation(
getMockCheckPrivilegesFailure
);
const options = Object.freeze({ type: type1, namespaces: ['some-ns'] });
await expectForbiddenError(client.find, { options });
const result = await client.find(options);
expect(clientOpts.baseClient.find).not.toHaveBeenCalled();
expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1);
expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith(
USERNAME,
'find',
[type1],
options.namespaces,
[{ spaceId: 'some-ns', privilege: 'mock-saved_object:foo/find' }],
{ options }
);
expect(result).toEqual({ page: 1, per_page: 20, total: 0, saved_objects: [] });
});
test(`throws decorated ForbiddenError when type's an array and unauthorized`, async () => {
const options = Object.freeze({ type: [type1, type2], namespaces: ['some-ns'] });
await expectForbiddenError(client.find, { options });
});
test(`returns result of baseClient.find when authorized`, async () => {
test(`returns result of baseClient.find when fully authorized`, async () => {
const apiCallReturnValue = { saved_objects: [], foo: 'bar' };
clientOpts.baseClient.find.mockReturnValue(apiCallReturnValue as any);
const options = Object.freeze({ type: type1, namespaces: ['some-ns'] });
const result = await expectSuccess(client.find, { options });
expect(clientOpts.baseClient.find.mock.calls[0][0]).toEqual({
...options,
typeToNamespacesMap: undefined,
});
expect(result).toEqual(apiCallReturnValue);
});
test(`returns result of baseClient.find when partially authorized`, async () => {
clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({
hasAllRequested: false,
username: USERNAME,
privileges: {
kibana: [
{ resource: 'some-ns', privilege: 'mock-saved_object:foo/find', authorized: true },
{ resource: 'some-ns', privilege: 'mock-saved_object:bar/find', authorized: true },
{ resource: 'some-ns', privilege: 'mock-saved_object:baz/find', authorized: false },
{ resource: 'some-ns', privilege: 'mock-saved_object:qux/find', authorized: false },
{ resource: 'another-ns', privilege: 'mock-saved_object:foo/find', authorized: true },
{ resource: 'another-ns', privilege: 'mock-saved_object:bar/find', authorized: false },
{ resource: 'another-ns', privilege: 'mock-saved_object:baz/find', authorized: true },
{ resource: 'another-ns', privilege: 'mock-saved_object:qux/find', authorized: false },
{ resource: 'forbidden-ns', privilege: 'mock-saved_object:foo/find', authorized: false },
{ resource: 'forbidden-ns', privilege: 'mock-saved_object:bar/find', authorized: false },
{ resource: 'forbidden-ns', privilege: 'mock-saved_object:baz/find', authorized: false },
{ resource: 'forbidden-ns', privilege: 'mock-saved_object:qux/find', authorized: false },
],
},
});
const apiCallReturnValue = { saved_objects: [], foo: 'bar' };
clientOpts.baseClient.find.mockReturnValue(apiCallReturnValue as any);
const options = Object.freeze({
type: ['foo', 'bar', 'baz', 'qux'],
namespaces: ['some-ns', 'another-ns', 'forbidden-ns'],
});
const result = await client.find(options);
// 'expect(clientOpts.baseClient.find).toHaveBeenCalledWith' resulted in false negatives, resorting to manually comparing mock call args
expect(clientOpts.baseClient.find.mock.calls[0][0]).toEqual({
...options,
typeToNamespacesMap: new Map([
['foo', ['some-ns', 'another-ns']],
['bar', ['some-ns']],
['baz', ['another-ns']],
// qux is not authorized, so there is no entry for it
// forbidden-ns is completely forbidden, so there are no entries with this namespace
]),
type: '',
namespaces: [],
});
expect(result).toEqual(apiCallReturnValue);
});

View file

@ -16,6 +16,7 @@ import {
SavedObjectsUpdateOptions,
SavedObjectsAddToNamespacesOptions,
SavedObjectsDeleteFromNamespacesOptions,
SavedObjectsUtils,
} from '../../../../../src/core/server';
import { SecurityAuditLogger } from '../audit';
import { Actions, CheckSavedObjectsPrivileges } from '../authorization';
@ -39,8 +40,19 @@ interface SavedObjectsNamespaces {
saved_objects: SavedObjectNamespaces[];
}
function uniq<T>(arr: T[]): T[] {
return Array.from(new Set<T>(arr));
interface EnsureAuthorizedOptions {
args?: Record<string, unknown>;
auditAction?: string;
requireFullAuthorization?: boolean;
}
interface EnsureAuthorizedResult {
status: 'fully_authorized' | 'partially_authorized' | 'unauthorized';
typeMap: Map<string, EnsureAuthorizedTypeResult>;
}
interface EnsureAuthorizedTypeResult {
authorizedSpaces: string[];
isGloballyAuthorized?: boolean;
}
export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContract {
@ -72,7 +84,8 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
attributes: T = {} as T,
options: SavedObjectsCreateOptions = {}
) {
await this.ensureAuthorized(type, 'create', options.namespace, { type, attributes, options });
const args = { type, attributes, options };
await this.ensureAuthorized(type, 'create', options.namespace, { args });
const savedObject = await this.baseClient.create(type, attributes, options);
return await this.redactSavedObjectNamespaces(savedObject);
@ -82,9 +95,12 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
objects: SavedObjectsCheckConflictsObject[] = [],
options: SavedObjectsBaseOptions = {}
) {
const types = this.getUniqueObjectTypes(objects);
const args = { objects, options };
await this.ensureAuthorized(types, 'bulk_create', options.namespace, args, 'checkConflicts');
const types = this.getUniqueObjectTypes(objects);
await this.ensureAuthorized(types, 'bulk_create', options.namespace, {
args,
auditAction: 'checkConflicts',
});
const response = await this.baseClient.checkConflicts(objects, options);
return response;
@ -94,11 +110,12 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
objects: Array<SavedObjectsBulkCreateObject<T>>,
options: SavedObjectsBaseOptions = {}
) {
const args = { objects, options };
await this.ensureAuthorized(
this.getUniqueObjectTypes(objects),
'bulk_create',
options.namespace,
{ objects, options }
{ args }
);
const response = await this.baseClient.bulkCreate(objects, options);
@ -106,7 +123,8 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
}
public async delete(type: string, id: string, options: SavedObjectsBaseOptions = {}) {
await this.ensureAuthorized(type, 'delete', options.namespace, { type, id, options });
const args = { type, id, options };
await this.ensureAuthorized(type, 'delete', options.namespace, { args });
return await this.baseClient.delete(type, id, options);
}
@ -121,9 +139,29 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
`_find across namespaces is not permitted when the Spaces plugin is disabled.`
);
}
await this.ensureAuthorized(options.type, 'find', options.namespaces, { options });
const args = { options };
const { status, typeMap } = await this.ensureAuthorized(
options.type,
'find',
options.namespaces,
{ args, requireFullAuthorization: false }
);
const response = await this.baseClient.find<T>(options);
if (status === 'unauthorized') {
// return empty response
return SavedObjectsUtils.createEmptyFindResponse<T>(options);
}
const typeToNamespacesMap = Array.from(typeMap).reduce<Map<string, string[] | undefined>>(
(acc, [type, { authorizedSpaces, isGloballyAuthorized }]) =>
isGloballyAuthorized ? acc.set(type, options.namespaces) : acc.set(type, authorizedSpaces),
new Map()
);
const response = await this.baseClient.find<T>({
...options,
typeToNamespacesMap: undefined, // if the user is fully authorized, use `undefined` as the typeToNamespacesMap to prevent privilege escalation
...(status === 'partially_authorized' && { typeToNamespacesMap, type: '', namespaces: [] }), // the repository requires that `type` and `namespaces` must be empty if `typeToNamespacesMap` is defined
});
return await this.redactSavedObjectsNamespaces(response);
}
@ -131,9 +169,9 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
objects: SavedObjectsBulkGetObject[] = [],
options: SavedObjectsBaseOptions = {}
) {
const args = { objects, options };
await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_get', options.namespace, {
objects,
options,
args,
});
const response = await this.baseClient.bulkGet<T>(objects, options);
@ -141,7 +179,8 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
}
public async get<T = unknown>(type: string, id: string, options: SavedObjectsBaseOptions = {}) {
await this.ensureAuthorized(type, 'get', options.namespace, { type, id, options });
const args = { type, id, options };
await this.ensureAuthorized(type, 'get', options.namespace, { args });
const savedObject = await this.baseClient.get<T>(type, id, options);
return await this.redactSavedObjectNamespaces(savedObject);
@ -154,7 +193,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
options: SavedObjectsUpdateOptions = {}
) {
const args = { type, id, attributes, options };
await this.ensureAuthorized(type, 'update', options.namespace, args);
await this.ensureAuthorized(type, 'update', options.namespace, { args });
const savedObject = await this.baseClient.update(type, id, attributes, options);
return await this.redactSavedObjectNamespaces(savedObject);
@ -169,13 +208,19 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
const args = { type, id, namespaces, options };
const { namespace } = options;
// To share an object, the user must have the "create" permission in each of the destination namespaces.
await this.ensureAuthorized(type, 'create', namespaces, args, 'addToNamespacesCreate');
await this.ensureAuthorized(type, 'create', namespaces, {
args,
auditAction: 'addToNamespacesCreate',
});
// To share an object, the user must also have the "update" permission in one or more of the source namespaces. Because the
// `addToNamespaces` operation is scoped to the current namespace, we can just check if the user has the "update" permission in the
// current namespace. If the user has permission, but the saved object doesn't exist in this namespace, the base client operation will
// result in a 404 error.
await this.ensureAuthorized(type, 'update', namespace, args, 'addToNamespacesUpdate');
await this.ensureAuthorized(type, 'update', namespace, {
args,
auditAction: 'addToNamespacesUpdate',
});
const result = await this.baseClient.addToNamespaces(type, id, namespaces, options);
return await this.redactSavedObjectNamespaces(result);
@ -189,7 +234,10 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
) {
const args = { type, id, namespaces, options };
// To un-share an object, the user must have the "delete" permission in each of the target namespaces.
await this.ensureAuthorized(type, 'delete', namespaces, args, 'deleteFromNamespaces');
await this.ensureAuthorized(type, 'delete', namespaces, {
args,
auditAction: 'deleteFromNamespaces',
});
const result = await this.baseClient.deleteFromNamespaces(type, id, namespaces, options);
return await this.redactSavedObjectNamespaces(result);
@ -205,9 +253,9 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
.filter(({ namespace }) => namespace !== undefined)
.map(({ namespace }) => namespace!);
const namespaces = [options?.namespace, ...objectNamespaces];
const args = { objects, options };
await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_update', namespaces, {
objects,
options,
args,
});
const response = await this.baseClient.bulkUpdate<T>(objects, options);
@ -228,11 +276,10 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
private async ensureAuthorized(
typeOrTypes: string | string[],
action: string,
namespaceOrNamespaces?: string | Array<undefined | string>,
args?: Record<string, unknown>,
auditAction: string = action,
requiresAll = true
) {
namespaceOrNamespaces: undefined | string | Array<undefined | string>,
options: EnsureAuthorizedOptions = {}
): Promise<EnsureAuthorizedResult> {
const { args, auditAction = action, requireFullAuthorization = true } = options;
const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes];
const actionsToTypesMap = new Map(
types.map((type) => [this.actions.savedObject.get(type, action), type])
@ -245,19 +292,24 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
privileges.kibana.map(({ resource }) => resource).filter((x) => x !== undefined)
).sort() as string[];
const isAuthorized =
(requiresAll && hasAllRequested) ||
(!requiresAll && privileges.kibana.some(({ authorized }) => authorized));
if (isAuthorized) {
this.auditLogger.savedObjectsAuthorizationSuccess(
username,
auditAction,
types,
spaceIds,
args
);
} else {
const missingPrivileges = this.getMissingPrivileges(privileges);
const missingPrivileges = this.getMissingPrivileges(privileges);
const typeMap = privileges.kibana.reduce<Map<string, EnsureAuthorizedTypeResult>>(
(acc, { resource, privilege, authorized }) => {
if (!authorized) {
return acc;
}
const type = actionsToTypesMap.get(privilege)!; // always defined
const value = acc.get(type) ?? { authorizedSpaces: [] };
if (resource === undefined) {
return acc.set(type, { ...value, isGloballyAuthorized: true });
}
const authorizedSpaces = value.authorizedSpaces.concat(resource);
return acc.set(type, { ...value, authorizedSpaces });
},
new Map()
);
const logAuthorizationFailure = () => {
this.auditLogger.savedObjectsAuthorizationFailure(
username,
auditAction,
@ -266,6 +318,34 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
missingPrivileges,
args
);
};
const logAuthorizationSuccess = (typeArray: string[], spaceIdArray: string[]) => {
this.auditLogger.savedObjectsAuthorizationSuccess(
username,
auditAction,
typeArray,
spaceIdArray,
args
);
};
if (hasAllRequested) {
logAuthorizationSuccess(types, spaceIds);
return { typeMap, status: 'fully_authorized' };
} else if (!requireFullAuthorization) {
const isPartiallyAuthorized = privileges.kibana.some(({ authorized }) => authorized);
if (isPartiallyAuthorized) {
for (const [type, { isGloballyAuthorized, authorizedSpaces }] of typeMap.entries()) {
// generate an individual audit record for each authorized type
logAuthorizationSuccess([type], isGloballyAuthorized ? spaceIds : authorizedSpaces);
}
return { typeMap, status: 'partially_authorized' };
} else {
logAuthorizationFailure();
return { typeMap, status: 'unauthorized' };
}
} else {
logAuthorizationFailure();
const targetTypes = uniq(
missingPrivileges.map(({ privilege }) => actionsToTypesMap.get(privilege)).sort()
).join(',');
@ -303,19 +383,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
}
private redactAndSortNamespaces(spaceIds: string[], privilegeMap: Record<string, boolean>) {
const comparator = (a: string, b: string) => {
const _a = a.toLowerCase();
const _b = b.toLowerCase();
if (_a === '?') {
return 1;
} else if (_a < _b) {
return -1;
} else if (_a > _b) {
return 1;
}
return 0;
};
return spaceIds.map((spaceId) => (privilegeMap[spaceId] ? spaceId : '?')).sort(comparator);
return spaceIds.map((x) => (privilegeMap[x] ? x : '?')).sort(namespaceComparator);
}
private async redactSavedObjectNamespaces<T extends SavedObjectNamespaces>(
@ -362,3 +430,25 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
};
}
}
/**
* Returns all unique elements of an array.
*/
function uniq<T>(arr: T[]): T[] {
return Array.from(new Set<T>(arr));
}
/**
* Utility function to sort potentially redacted namespaces.
* Sorts in a case-insensitive manner, and ensures that redacted namespaces ('?') always show up at the end of the array.
*/
function namespaceComparator(a: string, b: string) {
const A = a.toUpperCase();
const B = b.toUpperCase();
if (A === '?' && B !== '?') {
return 1;
} else if (A !== '?' && B === '?') {
return -1;
}
return A > B ? 1 : A < B ? -1 : 0;
}

View file

@ -104,7 +104,7 @@ export class SpacesClient {
`SpacesClient.getAll(), using RBAC. returning 403/Forbidden. Not authorized for any spaces for ${purpose} purpose.`
);
this.auditLogger.spacesAuthorizationFailure(username, 'getAll');
throw Boom.forbidden();
throw Boom.forbidden(); // Note: there is a catch for this in `SpacesSavedObjectsClient.find`; if we get rid of this error, remove that too
}
this.auditLogger.spacesAuthorizationSuccess(username, 'getAll', authorized as string[]);

View file

@ -10,6 +10,8 @@ import { spacesServiceMock } from '../spaces_service/spaces_service.mock';
import { savedObjectsClientMock } from '../../../../../src/core/server/mocks';
import { SavedObjectTypeRegistry } from 'src/core/server';
import { SpacesClient } from '../lib/spaces_client';
import { spacesClientMock } from '../lib/spaces_client/spaces_client.mock';
import Boom from 'boom';
const typeRegistry = new SavedObjectTypeRegistry();
typeRegistry.registerType({
@ -129,6 +131,34 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces';
});
describe('#find', () => {
const EMPTY_RESPONSE = { saved_objects: [], total: 0, per_page: 20, page: 1 };
test(`returns empty result if user is unauthorized in this space`, async () => {
const { client, baseClient, spacesService } = await createSpacesSavedObjectsClient();
const spacesClient = spacesClientMock.create();
spacesClient.getAll.mockResolvedValue([]);
spacesService.scopedClient.mockResolvedValue(spacesClient);
const options = Object.freeze({ type: 'foo', namespaces: ['some-ns'] });
const actualReturnValue = await client.find(options);
expect(actualReturnValue).toEqual(EMPTY_RESPONSE);
expect(baseClient.find).not.toHaveBeenCalled();
});
test(`returns empty result if user is unauthorized in any space`, async () => {
const { client, baseClient, spacesService } = await createSpacesSavedObjectsClient();
const spacesClient = spacesClientMock.create();
spacesClient.getAll.mockRejectedValue(Boom.unauthorized());
spacesService.scopedClient.mockResolvedValue(spacesClient);
const options = Object.freeze({ type: 'foo', namespaces: ['some-ns'] });
const actualReturnValue = await client.find(options);
expect(actualReturnValue).toEqual(EMPTY_RESPONSE);
expect(baseClient.find).not.toHaveBeenCalled();
});
test(`passes options.type to baseClient if valid singular type specified`, async () => {
const { client, baseClient } = await createSpacesSavedObjectsClient();
const expectedReturnValue = {

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import {
SavedObjectsBaseOptions,
SavedObjectsBulkCreateObject,
@ -16,8 +17,9 @@ import {
SavedObjectsUpdateOptions,
SavedObjectsAddToNamespacesOptions,
SavedObjectsDeleteFromNamespacesOptions,
SavedObjectsUtils,
ISavedObjectTypeRegistry,
} from 'src/core/server';
} from '../../../../../src/core/server';
import { SpacesServiceSetup } from '../spaces_service/spaces_service';
import { spaceIdToNamespace } from '../lib/utils/namespace';
import { SpacesClient } from '../lib/spaces_client';
@ -164,19 +166,26 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract {
let namespaces = options.namespaces;
if (namespaces) {
const spacesClient = await this.getSpacesClient;
const availableSpaces = await spacesClient.getAll('findSavedObjects');
if (namespaces.includes('*')) {
namespaces = availableSpaces.map((space) => space.id);
} else {
namespaces = namespaces.filter((namespace) =>
availableSpaces.some((space) => space.id === namespace)
);
}
// This forbidden error allows this scenario to be consistent
// with the way the SpacesClient behaves when no spaces are authorized
// there.
if (namespaces.length === 0) {
throw this.errors.decorateForbiddenError(new Error());
try {
const availableSpaces = await spacesClient.getAll('findSavedObjects');
if (namespaces.includes('*')) {
namespaces = availableSpaces.map((space) => space.id);
} else {
namespaces = namespaces.filter((namespace) =>
availableSpaces.some((space) => space.id === namespace)
);
}
if (namespaces.length === 0) {
// return empty response, since the user is unauthorized in this space (or these spaces), but we don't return forbidden errors for `find` operations
return SavedObjectsUtils.createEmptyFindResponse<T>(options);
}
} catch (err) {
if (Boom.isBoom(err) && err.output.payload.statusCode === 403) {
// return empty response, since the user is unauthorized in any space, but we don't return forbidden errors for `find` operations
return SavedObjectsUtils.createEmptyFindResponse<T>(options);
}
throw err;
}
} else {
namespaces = [this.spaceId];

View file

@ -4,30 +4,48 @@
* you may not use this file except in compliance with the Elastic License.
*/
export const SAVED_OBJECT_TEST_CASES = Object.freeze({
import { SPACES } from './spaces';
import { TestCase } from './types';
const {
DEFAULT: { spaceId: DEFAULT_SPACE_ID },
SPACE_1: { spaceId: SPACE_1_ID },
SPACE_2: { spaceId: SPACE_2_ID },
} = SPACES;
const EACH_SPACE = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID];
type CommonTestCase = Omit<TestCase, 'failure'> & { originId?: string };
export const SAVED_OBJECT_TEST_CASES: Record<string, CommonTestCase> = Object.freeze({
SINGLE_NAMESPACE_DEFAULT_SPACE: Object.freeze({
type: 'isolatedtype',
id: 'defaultspace-isolatedtype-id',
expectedNamespaces: [DEFAULT_SPACE_ID],
}),
SINGLE_NAMESPACE_SPACE_1: Object.freeze({
type: 'isolatedtype',
id: 'space1-isolatedtype-id',
expectedNamespaces: [SPACE_1_ID],
}),
SINGLE_NAMESPACE_SPACE_2: Object.freeze({
type: 'isolatedtype',
id: 'space2-isolatedtype-id',
expectedNamespaces: [SPACE_2_ID],
}),
MULTI_NAMESPACE_DEFAULT_AND_SPACE_1: Object.freeze({
type: 'sharedtype',
id: 'default_and_space_1',
expectedNamespaces: [DEFAULT_SPACE_ID, SPACE_1_ID],
}),
MULTI_NAMESPACE_ONLY_SPACE_1: Object.freeze({
type: 'sharedtype',
id: 'only_space_1',
expectedNamespaces: [SPACE_1_ID],
}),
MULTI_NAMESPACE_ONLY_SPACE_2: Object.freeze({
type: 'sharedtype',
id: 'only_space_2',
expectedNamespaces: [SPACE_2_ID],
}),
NAMESPACE_AGNOSTIC: Object.freeze({
type: 'globaltype',
@ -38,3 +56,37 @@ export const SAVED_OBJECT_TEST_CASES = Object.freeze({
id: 'any',
}),
});
/**
* These objects exist in the test data for all saved object test suites, but they are only used to test various conflict scenarios.
*/
export const CONFLICT_TEST_CASES: Record<string, CommonTestCase> = Object.freeze({
CONFLICT_1_OBJ: Object.freeze({
type: 'sharedtype',
id: 'conflict_1',
expectedNamespaces: EACH_SPACE,
}),
CONFLICT_2A_OBJ: Object.freeze({
type: 'sharedtype',
id: 'conflict_2a',
originId: 'conflict_2',
expectedNamespaces: EACH_SPACE,
}),
CONFLICT_2B_OBJ: Object.freeze({
type: 'sharedtype',
id: 'conflict_2b',
originId: 'conflict_2',
expectedNamespaces: EACH_SPACE,
}),
CONFLICT_3_OBJ: Object.freeze({
type: 'sharedtype',
id: 'conflict_3',
expectedNamespaces: EACH_SPACE,
}),
CONFLICT_4A_OBJ: Object.freeze({
type: 'sharedtype',
id: 'conflict_4a',
originId: 'conflict_4',
expectedNamespaces: EACH_SPACE,
}),
});

View file

@ -6,7 +6,6 @@
import expect from '@kbn/expect';
import { SavedObjectsErrorHelpers } from '../../../../../src/core/server';
import { SAVED_OBJECT_TEST_CASES as CASES } from './saved_object_test_cases';
import { SPACES } from './spaces';
import { AUTHENTICATION } from './authentication';
import { TestCase, TestUser, ExpectResponseBody } from './types';
@ -73,6 +72,28 @@ export const getTestTitle = (
return `${list.join(' and ')}`;
};
export const isUserAuthorizedAtSpace = (user: TestUser | undefined, namespace: string) =>
!user || user.authorizedAtSpaces.includes('*') || user.authorizedAtSpaces.includes(namespace);
export const getRedactedNamespaces = (
user: TestUser | undefined,
namespaces: string[] | undefined
) => namespaces?.map((x) => (isUserAuthorizedAtSpace(user, x) ? x : '?')).sort(namespaceComparator);
function namespaceComparator(a: string, b: string) {
// namespaces get sorted so that they're all in alphabetical order, and unknown ones appear at the end
// this is to prevent information disclosure
if (a === '?' && b !== '?') {
return 1;
} else if (b === '?' && a !== '?') {
return -1;
} else if (a > b) {
return 1;
} else if (a < b) {
return -1;
}
return 0;
}
export const testCaseFailures = {
// test suites need explicit return types for number primitives
fail400: (condition?: boolean): { failure?: 400 } =>
@ -150,7 +171,7 @@ export const expectResponses = {
}
},
/**
* Additional assertions that we use in `bulk_create` and `create` to ensure that
* Additional assertions that we use in `import` and `resolve_import_errors` to ensure that
* newly-created (or overwritten) objects don't have unexpected properties
*/
successCreated: async (es: any, spaceId: string, type: string, id: string) => {
@ -161,26 +182,6 @@ export const expectResponses = {
id: `${expectedSpacePrefix}${type}:${id}`,
index: '.kibana',
});
const { namespace: actualNamespace, namespaces: actualNamespaces } = savedObject._source;
if (isNamespaceUndefined) {
expect(actualNamespace).to.eql(undefined);
} else {
expect(actualNamespace).to.eql(spaceId);
}
if (isMultiNamespace(type)) {
if (['conflict_1', 'conflict_2a', 'conflict_2b', 'conflict_3', 'conflict_4a'].includes(id)) {
expect(actualNamespaces).to.eql([DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]);
} else if (id === CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1.id) {
expect(actualNamespaces).to.eql([DEFAULT_SPACE_ID, SPACE_1_ID]);
} else if (id === CASES.MULTI_NAMESPACE_ONLY_SPACE_1.id) {
expect(actualNamespaces).to.eql([SPACE_1_ID]);
} else if (id === CASES.MULTI_NAMESPACE_ONLY_SPACE_2.id) {
expect(actualNamespaces).to.eql([SPACE_2_ID]);
} else {
// newly created in this space
expect(actualNamespaces).to.eql([spaceId]);
}
}
return savedObject;
},
};

View file

@ -21,6 +21,7 @@ export interface TestSuite<T> {
export interface TestCase {
type: string;
id: string;
expectedNamespaces?: string[];
failure?: 400 | 403 | 404 | 409;
}

View file

@ -14,8 +14,9 @@ import {
expectResponses,
getUrlPrefix,
getTestTitle,
getRedactedNamespaces,
} from '../lib/saved_object_test_utils';
import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types';
import { ExpectResponseBody, TestCase, TestDefinition, TestSuite, TestUser } from '../lib/types';
export interface BulkCreateTestDefinition extends TestDefinition {
request: Array<{ type: string; id: string }>;
@ -33,7 +34,7 @@ const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`;
const NEW_SINGLE_NAMESPACE_OBJ = Object.freeze({ type: 'dashboard', id: 'new-dashboard-id' });
const NEW_MULTI_NAMESPACE_OBJ = Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' });
const NEW_NAMESPACE_AGNOSTIC_OBJ = Object.freeze({ type: 'globaltype', id: 'new-globaltype-id' });
export const TEST_CASES = Object.freeze({
export const TEST_CASES: Record<string, BulkCreateTestCase> = Object.freeze({
...CASES,
NEW_SINGLE_NAMESPACE_OBJ,
NEW_MULTI_NAMESPACE_OBJ,
@ -45,7 +46,7 @@ export function bulkCreateTestSuiteFactory(es: any, esArchiver: any, supertest:
const expectResponseBody = (
testCases: BulkCreateTestCase | BulkCreateTestCase[],
statusCode: 200 | 403,
spaceId = SPACES.DEFAULT.spaceId
user?: TestUser
): ExpectResponseBody => async (response: Record<string, any>) => {
const testCaseArray = Array.isArray(testCases) ? testCases : [testCases];
if (statusCode === 403) {
@ -70,7 +71,8 @@ export function bulkCreateTestSuiteFactory(es: any, esArchiver: any, supertest:
await expectResponses.permitted(object, testCase);
if (!testCase.failure) {
expect(object.attributes[NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL);
await expectResponses.successCreated(es, spaceId, object.type, object.id);
const redactedNamespaces = getRedactedNamespaces(user, testCase.expectedNamespaces);
expect(object.namespaces).to.eql(redactedNamespaces);
}
}
}
@ -81,6 +83,7 @@ export function bulkCreateTestSuiteFactory(es: any, esArchiver: any, supertest:
overwrite: boolean,
options?: {
spaceId?: string;
user?: TestUser;
singleRequest?: boolean;
responseBodyOverride?: ExpectResponseBody;
}
@ -95,8 +98,7 @@ export function bulkCreateTestSuiteFactory(es: any, esArchiver: any, supertest:
request: [createRequest(x)],
responseStatusCode,
responseBody:
options?.responseBodyOverride ||
expectResponseBody(x, responseStatusCode, options?.spaceId),
options?.responseBodyOverride || expectResponseBody(x, responseStatusCode, options?.user),
overwrite,
}));
}
@ -108,7 +110,7 @@ export function bulkCreateTestSuiteFactory(es: any, esArchiver: any, supertest:
responseStatusCode,
responseBody:
options?.responseBodyOverride ||
expectResponseBody(cases, responseStatusCode, options?.spaceId),
expectResponseBody(cases, responseStatusCode, options?.user),
overwrite,
},
];

View file

@ -25,7 +25,10 @@ export interface BulkGetTestCase extends TestCase {
}
const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' });
export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST });
export const TEST_CASES: Record<string, BulkGetTestCase> = Object.freeze({
...CASES,
DOES_NOT_EXIST,
});
export function bulkGetTestSuiteFactory(esArchiver: any, supertest: SuperTest<any>) {
const expectForbidden = expectResponses.forbiddenTypes('bulk_get');

View file

@ -24,7 +24,10 @@ const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute,
const NEW_ATTRIBUTE_VAL = `Updated attribute value ${Date.now()}`;
const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' });
export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST });
export const TEST_CASES: Record<string, BulkUpdateTestCase> = Object.freeze({
...CASES,
DOES_NOT_EXIST,
});
const createRequest = ({ type, id, namespace }: BulkUpdateTestCase) => ({
type,

View file

@ -13,8 +13,9 @@ import {
expectResponses,
getUrlPrefix,
getTestTitle,
getRedactedNamespaces,
} from '../lib/saved_object_test_utils';
import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types';
import { ExpectResponseBody, TestCase, TestDefinition, TestSuite, TestUser } from '../lib/types';
export interface CreateTestDefinition extends TestDefinition {
request: { type: string; id: string };
@ -33,7 +34,7 @@ const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`;
const NEW_SINGLE_NAMESPACE_OBJ = Object.freeze({ type: 'dashboard', id: '' });
const NEW_MULTI_NAMESPACE_OBJ = Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' });
const NEW_NAMESPACE_AGNOSTIC_OBJ = Object.freeze({ type: 'globaltype', id: 'new-globaltype-id' });
export const TEST_CASES = Object.freeze({
export const TEST_CASES: Record<string, CreateTestCase> = Object.freeze({
...CASES,
NEW_SINGLE_NAMESPACE_OBJ,
NEW_MULTI_NAMESPACE_OBJ,
@ -44,7 +45,7 @@ export function createTestSuiteFactory(es: any, esArchiver: any, supertest: Supe
const expectForbidden = expectResponses.forbiddenTypes('create');
const expectResponseBody = (
testCase: CreateTestCase,
spaceId = SPACES.DEFAULT.spaceId
user?: TestUser
): ExpectResponseBody => async (response: Record<string, any>) => {
if (testCase.failure === 403) {
await expectForbidden(testCase.type)(response);
@ -54,7 +55,8 @@ export function createTestSuiteFactory(es: any, esArchiver: any, supertest: Supe
await expectResponses.permitted(object, testCase);
if (!testCase.failure) {
expect(object.attributes[NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL);
await expectResponses.successCreated(es, spaceId, object.type, object.id);
const redactedNamespaces = getRedactedNamespaces(user, testCase.expectedNamespaces);
expect(object.namespaces).to.eql(redactedNamespaces);
}
}
};
@ -64,6 +66,7 @@ export function createTestSuiteFactory(es: any, esArchiver: any, supertest: Supe
overwrite: boolean,
options?: {
spaceId?: string;
user?: TestUser;
responseBodyOverride?: ExpectResponseBody;
}
): CreateTestDefinition[] => {
@ -76,7 +79,7 @@ export function createTestSuiteFactory(es: any, esArchiver: any, supertest: Supe
title: getTestTitle(x),
responseStatusCode: x.failure ?? 200,
request: createRequest(x),
responseBody: options?.responseBodyOverride || expectResponseBody(x, options?.spaceId),
responseBody: options?.responseBodyOverride || expectResponseBody(x, options?.user),
overwrite,
}));
};

View file

@ -25,7 +25,10 @@ export interface DeleteTestCase extends TestCase {
}
const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' });
export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST });
export const TEST_CASES: Record<string, DeleteTestCase> = Object.freeze({
...CASES,
DOES_NOT_EXIST,
});
export function deleteTestSuiteFactory(esArchiver: any, supertest: SuperTest<any>) {
const expectForbidden = expectResponses.forbiddenTypes('delete');

View file

@ -30,7 +30,10 @@ export interface ExportTestCase {
type: string;
id?: string;
successResult?: SuccessResult | SuccessResult[];
failure?: 400 | 403;
failure?: {
statusCode: 200 | 400 | 403; // if the user searches for only types they are not authorized for, they will get an empty 200 result
reason: 'unauthorized' | 'bad_request';
};
}
// additional sharedtype objects that exist but do not have common test cases defined
@ -90,41 +93,45 @@ export const getTestCases = (spaceId?: string): { [key: string]: ExportTestCase
type: 'globaltype',
successResult: CASES.NAMESPACE_AGNOSTIC,
},
hiddenObject: { title: 'hidden object', ...CASES.HIDDEN, failure: 400 },
hiddenType: { title: 'hidden type', type: 'hiddentype', failure: 400 },
hiddenObject: {
title: 'hidden object',
...CASES.HIDDEN,
failure: { statusCode: 400, reason: 'bad_request' },
},
hiddenType: {
title: 'hidden type',
type: 'hiddentype',
failure: { statusCode: 400, reason: 'bad_request' },
},
});
export const createRequest = ({ type, id }: ExportTestCase) =>
id ? { objects: [{ type, id }] } : { type };
const getTestTitle = ({ failure, title }: ExportTestCase) => {
let description = 'success';
if (failure === 400) {
description = 'bad request';
} else if (failure === 403) {
description = 'forbidden';
}
return `${description} ["${title}"]`;
};
const getTestTitle = ({ failure, title }: ExportTestCase) =>
`${failure?.reason || 'success'} ["${title}"]`;
const EMPTY_RESULT = { exportedCount: 0, missingRefCount: 0, missingReferences: [] };
export function exportTestSuiteFactory(esArchiver: any, supertest: SuperTest<any>) {
const expectForbiddenBulkGet = expectResponses.forbiddenTypes('bulk_get');
const expectForbiddenFind = expectResponses.forbiddenTypes('find');
const expectResponseBody = (testCase: ExportTestCase): ExpectResponseBody => async (
response: Record<string, any>
) => {
const { type, id, successResult = { type, id } as SuccessResult, failure } = testCase;
if (failure === 403) {
// In export only, the API uses "bulk_get" or "find" depending on the parameters it receives.
// The best that could be done here is to have an if statement to ensure at least one of the
// two errors has been thrown.
if (id) {
if (failure?.reason === 'unauthorized') {
// In export only, the API uses "bulkGet" or "find" depending on the parameters it receives.
if (failure.statusCode === 403) {
// "bulkGet" was unauthorized, which returns a forbidden error
await expectForbiddenBulkGet(type)(response);
} else if (failure.statusCode === 200) {
// "find" was unauthorized, which returns an empty result
expect(response.body).not.to.have.property('error');
expect(response.text).to.equal(JSON.stringify(EMPTY_RESULT));
} else {
await expectForbiddenFind(type)(response);
throw new Error(`Unexpected failure status code: ${failure.statusCode}`);
}
} else if (failure === 400) {
// 400
} else if (failure?.reason === 'bad_request') {
expect(response.body.error).to.eql('Bad Request');
expect(response.body.statusCode).to.eql(failure);
expect(response.body.statusCode).to.eql(failure.statusCode);
if (id) {
expect(response.body.message).to.eql(
`Trying to export object(s) with non-exportable types: ${type}:${id}`
@ -132,6 +139,8 @@ export function exportTestSuiteFactory(esArchiver: any, supertest: SuperTest<any
} else {
expect(response.body.message).to.eql(`Trying to export non-exportable type(s): ${type}`);
}
} else if (failure?.reason) {
throw new Error(`Unexpected failure reason: ${failure.reason}`);
} else {
// 2xx
expect(response.body).not.to.have.property('error');
@ -159,19 +168,19 @@ export function exportTestSuiteFactory(esArchiver: any, supertest: SuperTest<any
};
const createTestDefinitions = (
testCases: ExportTestCase | ExportTestCase[],
forbidden: boolean,
failure: ExportTestCase['failure'] | false,
options?: {
responseBodyOverride?: ExpectResponseBody;
}
): ExportTestDefinition[] => {
let cases = Array.isArray(testCases) ? testCases : [testCases];
if (forbidden) {
if (failure) {
// override the expected result in each test case
cases = cases.map((x) => ({ ...x, failure: 403 }));
cases = cases.map((x) => ({ ...x, failure }));
}
return cases.map((x) => ({
title: getTestTitle(x),
responseStatusCode: x.failure ?? 200,
responseStatusCode: x.failure?.statusCode ?? 200,
request: createRequest(x),
responseBody: options?.responseBodyOverride || expectResponseBody(x),
}));

View file

@ -7,10 +7,13 @@
import expect from '@kbn/expect';
import { SuperTest } from 'supertest';
import querystring from 'querystring';
import { Assign } from '@kbn/utility-types';
import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases';
import { SAVED_OBJECT_TEST_CASES, CONFLICT_TEST_CASES } from '../lib/saved_object_test_cases';
import { SPACES } from '../lib/spaces';
import { expectResponses, getUrlPrefix } from '../lib/saved_object_test_utils';
import {
getUrlPrefix,
isUserAuthorizedAtSpace,
getRedactedNamespaces,
} from '../lib/saved_object_test_utils';
import { ExpectResponseBody, TestCase, TestDefinition, TestSuite, TestUser } from '../lib/types';
const {
@ -22,80 +25,34 @@ export interface FindTestDefinition extends TestDefinition {
}
export type FindTestSuite = TestSuite<FindTestDefinition>;
type FindSavedObjectCase = Assign<TestCase, { namespaces: string[] }>;
export interface FindTestCase {
title: string;
query: string;
successResult?: {
savedObjects?: FindSavedObjectCase | FindSavedObjectCase[];
savedObjects?: TestCase | TestCase[];
page?: number;
perPage?: number;
total?: number;
};
failure?: {
statusCode: 400 | 403;
reason:
| 'forbidden_types'
| 'forbidden_namespaces'
| 'cross_namespace_not_permitted'
| 'bad_request';
statusCode: 200 | 400; // if the user searches for types and/or namespaces they are not authorized for, they will get a 200 result with those types/namespaces omitted
reason: 'unauthorized' | 'cross_namespace_not_permitted' | 'bad_request';
};
}
// additional sharedtype objects that exist but do not have common test cases defined
const CONFLICT_1_OBJ = Object.freeze({
type: 'sharedtype',
id: 'conflict_1',
namespaces: ['default', 'space_1', 'space_2'],
});
const CONFLICT_2A_OBJ = Object.freeze({
type: 'sharedtype',
id: 'conflict_2a',
originId: 'conflict_2',
namespaces: ['default', 'space_1', 'space_2'],
});
const CONFLICT_2B_OBJ = Object.freeze({
type: 'sharedtype',
id: 'conflict_2b',
originId: 'conflict_2',
namespaces: ['default', 'space_1', 'space_2'],
});
const CONFLICT_3_OBJ = Object.freeze({
type: 'sharedtype',
id: 'conflict_3',
namespaces: ['default', 'space_1', 'space_2'],
});
const CONFLICT_4A_OBJ = Object.freeze({
type: 'sharedtype',
id: 'conflict_4a',
originId: 'conflict_4',
namespaces: ['default', 'space_1', 'space_2'],
});
const TEST_CASES = [
{ ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, namespaces: ['default'] },
{ ...CASES.SINGLE_NAMESPACE_SPACE_1, namespaces: ['space_1'] },
{ ...CASES.SINGLE_NAMESPACE_SPACE_2, namespaces: ['space_2'] },
{ ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, namespaces: ['default', 'space_1'] },
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, namespaces: ['space_1'] },
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, namespaces: ['space_2'] },
{ ...CASES.NAMESPACE_AGNOSTIC, namespaces: undefined },
{ ...CASES.HIDDEN, namespaces: undefined },
...Object.values(SAVED_OBJECT_TEST_CASES),
...Object.values(CONFLICT_TEST_CASES),
];
expect(TEST_CASES.length).to.eql(
Object.values(CASES).length,
'Unhandled test cases in `find` suite'
);
export const getTestCases = (
{ currentSpace, crossSpaceSearch }: { currentSpace?: string; crossSpaceSearch?: string[] } = {
currentSpace: undefined,
crossSpaceSearch: undefined,
}
) => {
const crossSpaceIds = crossSpaceSearch?.filter((s) => s !== (currentSpace ?? 'default')) ?? [];
const crossSpaceIds =
crossSpaceSearch?.filter((s) => s !== (currentSpace ?? DEFAULT_SPACE_ID)) ?? []; // intentionally exclude the current space
const isCrossSpaceSearch = crossSpaceIds.length > 0;
const isWildcardSearch = crossSpaceIds.includes('*');
@ -104,7 +61,7 @@ export const getTestCases = (
: '';
const buildTitle = (title: string) =>
crossSpaceSearch ? `${title} (cross-space ${isWildcardSearch ? 'with wildcard' : ''})` : title;
crossSpaceSearch ? `${title} (cross-space${isWildcardSearch ? ' with wildcard' : ''})` : title;
type CasePredicate = (testCase: TestCase) => boolean;
const getExpectedSavedObjects = (predicate: CasePredicate) => {
@ -117,13 +74,16 @@ export const getTestCases = (
return TEST_CASES.filter((t) => {
const hasOtherNamespaces =
Array.isArray(t.namespaces) &&
t.namespaces!.some((ns) => ns !== (currentSpace ?? 'default'));
!t.expectedNamespaces || // namespace-agnostic types do not have an expectedNamespaces field
t.expectedNamespaces.some((ns) => ns !== (currentSpace ?? DEFAULT_SPACE_ID));
return hasOtherNamespaces && predicate(t);
});
}
return TEST_CASES.filter(
(t) => (!t.namespaces || t.namespaces.includes(currentSpace ?? 'default')) && predicate(t)
(t) =>
(!t.expectedNamespaces ||
t.expectedNamespaces.includes(currentSpace ?? DEFAULT_SPACE_ID)) &&
predicate(t)
);
};
@ -140,19 +100,13 @@ export const getTestCases = (
query: `type=sharedtype&fields=title${namespacesQueryParam}`,
successResult: {
// expected depends on which spaces the user is authorized against...
savedObjects: getExpectedSavedObjects((t) => t.type === 'sharedtype').concat(
CONFLICT_1_OBJ,
CONFLICT_2A_OBJ,
CONFLICT_2B_OBJ,
CONFLICT_3_OBJ,
CONFLICT_4A_OBJ
),
savedObjects: getExpectedSavedObjects((t) => t.type === 'sharedtype'),
},
} as FindTestCase,
namespaceAgnosticType: {
title: buildTitle('find namespace-agnostic type'),
query: `type=globaltype&fields=title${namespacesQueryParam}`,
successResult: { savedObjects: CASES.NAMESPACE_AGNOSTIC },
successResult: { savedObjects: SAVED_OBJECT_TEST_CASES.NAMESPACE_AGNOSTIC },
} as FindTestCase,
hiddenType: {
title: buildTitle('find hidden type'),
@ -162,6 +116,15 @@ export const getTestCases = (
title: buildTitle('find unknown type'),
query: `type=wigwags${namespacesQueryParam}`,
} as FindTestCase,
eachType: {
title: buildTitle('find each type'),
query: `type=isolatedtype&type=sharedtype&type=globaltype&type=hiddentype&type=wigwags${namespacesQueryParam}`,
successResult: {
savedObjects: getExpectedSavedObjects((t) =>
['isolatedtype', 'sharedtype', 'globaltype'].includes(t.type)
),
},
} as FindTestCase,
pageBeyondTotal: {
title: buildTitle('find page beyond total'),
query: `type=isolatedtype&page=100&per_page=100${namespacesQueryParam}`,
@ -179,7 +142,7 @@ export const getTestCases = (
filterWithNamespaceAgnosticType: {
title: buildTitle('filter with namespace-agnostic type'),
query: `type=globaltype&filter=globaltype.attributes.title:*global*${namespacesQueryParam}`,
successResult: { savedObjects: CASES.NAMESPACE_AGNOSTIC },
successResult: { savedObjects: SAVED_OBJECT_TEST_CASES.NAMESPACE_AGNOSTIC },
} as FindTestCase,
filterWithHiddenType: {
title: buildTitle('filter with hidden type'),
@ -200,49 +163,48 @@ export const getTestCases = (
};
};
function objectComparator(a: { id: string }, b: { id: string }) {
return a.id > b.id ? 1 : a.id < b.id ? -1 : 0;
}
export const createRequest = ({ query }: FindTestCase) => ({ query });
const getTestTitle = ({ failure, title }: FindTestCase) => {
let description = 'success';
if (failure?.statusCode === 400) {
description = 'bad request';
} else if (failure?.statusCode === 403) {
description = 'forbidden';
}
return `${description} ["${title}"]`;
};
const getTestTitle = ({ failure, title }: FindTestCase) =>
`${failure?.reason || 'success'} ["${title}"]`;
export function findTestSuiteFactory(esArchiver: any, supertest: SuperTest<any>) {
const expectForbiddenTypes = expectResponses.forbiddenTypes('find');
const expectForbiddeNamespaces = expectResponses.forbiddenSpaces;
const expectResponseBody = (
testCase: FindTestCase,
user?: TestUser
): ExpectResponseBody => async (response: Record<string, any>) => {
const { failure, successResult = {}, query } = testCase;
const parsedQuery = querystring.parse(query);
if (failure?.statusCode === 403) {
if (failure?.reason === 'forbidden_types') {
const type = parsedQuery.type;
await expectForbiddenTypes(type)(response);
} else if (failure?.reason === 'forbidden_namespaces') {
await expectForbiddeNamespaces(response);
if (failure?.statusCode === 200) {
if (failure?.reason === 'unauthorized') {
// if the user is completely unauthorized, they will receive an empty response body
const expected = {
page: parsedQuery.page || 1,
per_page: parsedQuery.per_page || 20,
total: 0,
saved_objects: [],
};
expect(response.body).to.eql(expected);
} else {
throw new Error(`Unexpected failure reason: ${failure?.reason}`);
throw new Error(`Unexpected failure reason: ${failure.reason}`);
}
} else if (failure?.statusCode === 400) {
if (failure?.reason === 'bad_request') {
if (failure.reason === 'bad_request') {
const type = (parsedQuery.filter as string).split('.')[0];
expect(response.body.error).to.eql('Bad Request');
expect(response.body.statusCode).to.eql(failure?.statusCode);
expect(response.body.statusCode).to.eql(failure.statusCode);
expect(response.body.message).to.eql(`This type ${type} is not allowed: Bad Request`);
} else if (failure?.reason === 'cross_namespace_not_permitted') {
} else if (failure.reason === 'cross_namespace_not_permitted') {
expect(response.body.error).to.eql('Bad Request');
expect(response.body.statusCode).to.eql(failure?.statusCode);
expect(response.body.statusCode).to.eql(failure.statusCode);
expect(response.body.message).to.eql(
`_find across namespaces is not permitted when the Spaces plugin is disabled.: Bad Request`
);
} else {
throw new Error(`Unexpected failure reason: ${failure?.reason}`);
throw new Error(`Unexpected failure reason: ${failure.reason}`);
}
} else {
// 2xx
@ -251,11 +213,8 @@ export function findTestSuiteFactory(esArchiver: any, supertest: SuperTest<any>)
const savedObjectsArray = Array.isArray(savedObjects) ? savedObjects : [savedObjects];
const authorizedSavedObjects = savedObjectsArray.filter(
(so) =>
!user ||
!so.namespaces ||
so.namespaces.some(
(ns) => user.authorizedAtSpaces.includes(ns) || user.authorizedAtSpaces.includes('*')
)
!so.expectedNamespaces ||
so.expectedNamespaces.some((x) => isUserAuthorizedAtSpace(user, x))
);
expect(response.body.page).to.eql(page);
expect(response.body.per_page).to.eql(perPage);
@ -265,16 +224,17 @@ export function findTestSuiteFactory(esArchiver: any, supertest: SuperTest<any>)
expect(response.body.total).to.eql(total || authorizedSavedObjects.length);
}
authorizedSavedObjects.sort((s1, s2) => (s1.id < s2.id ? -1 : 1));
response.body.saved_objects.sort((s1: any, s2: any) => (s1.id < s2.id ? -1 : 1));
authorizedSavedObjects.sort(objectComparator);
response.body.saved_objects.sort(objectComparator);
for (let i = 0; i < authorizedSavedObjects.length; i++) {
const object = response.body.saved_objects[i];
const { type: expectedType, id: expectedId } = authorizedSavedObjects[i];
expect(object.type).to.eql(expectedType);
expect(object.id).to.eql(expectedId);
const expected = authorizedSavedObjects[i];
const expectedNamespaces = getRedactedNamespaces(user, expected.expectedNamespaces);
expect(object.type).to.eql(expected.type);
expect(object.id).to.eql(expected.id);
expect(object.updated_at).to.match(/^[\d-]{10}T[\d:\.]{12}Z$/);
expect(object.namespaces).to.eql(object.namespaces);
expect(object.namespaces).to.eql(expectedNamespaces);
// don't test attributes, version, or references
}
}

View file

@ -21,7 +21,7 @@ export type GetTestSuite = TestSuite<GetTestDefinition>;
export type GetTestCase = TestCase;
const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' });
export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST });
export const TEST_CASES: Record<string, GetTestCase> = Object.freeze({ ...CASES, DOES_NOT_EXIST });
export function getTestSuiteFactory(esArchiver: any, supertest: SuperTest<any>) {
const expectForbidden = expectResponses.forbiddenTypes('get');

View file

@ -36,7 +36,7 @@ const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`;
// * id: conflict_4a, originId: conflict_4
// using the seven conflict test case objects below, we can exercise various permutations of exact/inexact/ambiguous conflict scenarios
const CID = 'conflict_';
export const TEST_CASES = Object.freeze({
export const TEST_CASES: Record<string, ImportTestCase> = Object.freeze({
...CASES,
CONFLICT_1_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}1` }),
CONFLICT_1A_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}1a`, originId: `${CID}1` }),

View file

@ -37,7 +37,7 @@ const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`;
// * id: conflict_3
// * id: conflict_4a, originId: conflict_4
// using the five conflict test case objects below, we can exercise various permutations of exact/inexact/ambiguous conflict scenarios
export const TEST_CASES = Object.freeze({
export const TEST_CASES: Record<string, ResolveImportErrorsTestCase> = Object.freeze({
...CASES,
CONFLICT_1A_OBJ: Object.freeze({
type: 'sharedtype',

View file

@ -28,7 +28,10 @@ const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute,
const NEW_ATTRIBUTE_VAL = `Updated attribute value ${Date.now()}`;
const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' });
export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST });
export const TEST_CASES: Record<string, UpdateTestCase> = Object.freeze({
...CASES,
DOES_NOT_EXIST,
});
export function updateTestSuiteFactory(esArchiver: any, supertest: SuperTest<any>) {
const expectForbidden = expectResponses.forbiddenTypes('update');

View file

@ -26,13 +26,23 @@ const unresolvableConflict = (condition?: boolean) =>
const createTestCases = (overwrite: boolean, spaceId: string) => {
// for each permitted (non-403) outcome, if failure !== undefined then we expect
// to receive an error; otherwise, we expect to receive a success result
const expectedNamespaces = [spaceId]; // newly created objects should have this `namespaces` array in their return value
const normalTypes = [
{
...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE,
...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID),
expectedNamespaces,
},
{
...CASES.SINGLE_NAMESPACE_SPACE_1,
...fail409(!overwrite && spaceId === SPACE_1_ID),
expectedNamespaces,
},
{
...CASES.SINGLE_NAMESPACE_SPACE_2,
...fail409(!overwrite && spaceId === SPACE_2_ID),
expectedNamespaces,
},
{ ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) },
{ ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) },
{
...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1,
...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)),
@ -49,8 +59,8 @@ const createTestCases = (overwrite: boolean, spaceId: string) => {
...unresolvableConflict(spaceId !== SPACE_2_ID),
},
{ ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) },
CASES.NEW_SINGLE_NAMESPACE_OBJ,
CASES.NEW_MULTI_NAMESPACE_OBJ,
{ ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces },
{ ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces },
CASES.NEW_NAMESPACE_AGNOSTIC_OBJ,
];
const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }];
@ -68,22 +78,28 @@ export default function ({ getService }: FtrProviderContext) {
esArchiver,
supertest
);
const createTests = (overwrite: boolean, spaceId: string) => {
const createTests = (overwrite: boolean, spaceId: string, user: TestUser) => {
const { normalTypes, hiddenType, allTypes } = createTestCases(overwrite, spaceId);
// use singleRequest to reduce execution time and/or test combined cases
return {
unauthorized: createTestDefinitions(allTypes, true, overwrite, { spaceId }),
unauthorized: createTestDefinitions(allTypes, true, overwrite, { spaceId, user }),
authorized: [
createTestDefinitions(normalTypes, false, overwrite, { spaceId, singleRequest: true }),
createTestDefinitions(hiddenType, true, overwrite, { spaceId }),
createTestDefinitions(normalTypes, false, overwrite, {
spaceId,
user,
singleRequest: true,
}),
createTestDefinitions(hiddenType, true, overwrite, { spaceId, user }),
createTestDefinitions(allTypes, true, overwrite, {
spaceId,
user,
singleRequest: true,
responseBodyOverride: expectForbidden(['hiddentype']),
}),
].flat(),
superuser: createTestDefinitions(allTypes, false, overwrite, {
spaceId,
user,
singleRequest: true,
}),
};
@ -93,7 +109,6 @@ export default function ({ getService }: FtrProviderContext) {
getTestScenarios([false, true]).securityAndSpaces.forEach(
({ spaceId, users, modifier: overwrite }) => {
const suffix = ` within the ${spaceId} space${overwrite ? ' with overwrite enabled' : ''}`;
const { unauthorized, authorized, superuser } = createTests(overwrite!, spaceId);
const _addTests = (user: TestUser, tests: BulkCreateTestDefinition[]) => {
addTests(`${user.description}${suffix}`, { user, spaceId, tests });
};
@ -106,11 +121,14 @@ export default function ({ getService }: FtrProviderContext) {
users.readAtSpace,
users.allAtOtherSpace,
].forEach((user) => {
const { unauthorized } = createTests(overwrite!, spaceId, user);
_addTests(user, unauthorized);
});
[users.dualAll, users.allGlobally, users.allAtSpace].forEach((user) => {
const { authorized } = createTests(overwrite!, spaceId, user);
_addTests(user, authorized);
});
const { superuser } = createTests(overwrite!, spaceId, users.superuser);
_addTests(users.superuser, superuser);
}
);

View file

@ -24,13 +24,23 @@ const { fail400, fail409 } = testCaseFailures;
const createTestCases = (overwrite: boolean, spaceId: string) => {
// for each permitted (non-403) outcome, if failure !== undefined then we expect
// to receive an error; otherwise, we expect to receive a success result
const expectedNamespaces = [spaceId]; // newly created objects should have this `namespaces` array in their return value
const normalTypes = [
{
...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE,
...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID),
expectedNamespaces,
},
{
...CASES.SINGLE_NAMESPACE_SPACE_1,
...fail409(!overwrite && spaceId === SPACE_1_ID),
expectedNamespaces,
},
{
...CASES.SINGLE_NAMESPACE_SPACE_2,
...fail409(!overwrite && spaceId === SPACE_2_ID),
expectedNamespaces,
},
{ ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) },
{ ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) },
{
...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1,
...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)),
@ -38,8 +48,8 @@ const createTestCases = (overwrite: boolean, spaceId: string) => {
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) },
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) },
{ ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) },
CASES.NEW_SINGLE_NAMESPACE_OBJ,
CASES.NEW_MULTI_NAMESPACE_OBJ,
{ ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces },
{ ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces },
CASES.NEW_NAMESPACE_AGNOSTIC_OBJ,
];
const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }];
@ -53,15 +63,15 @@ export default function ({ getService }: FtrProviderContext) {
const es = getService('legacyEs');
const { addTests, createTestDefinitions } = createTestSuiteFactory(es, esArchiver, supertest);
const createTests = (overwrite: boolean, spaceId: string) => {
const createTests = (overwrite: boolean, spaceId: string, user: TestUser) => {
const { normalTypes, hiddenType, allTypes } = createTestCases(overwrite, spaceId);
return {
unauthorized: createTestDefinitions(allTypes, true, overwrite, { spaceId }),
unauthorized: createTestDefinitions(allTypes, true, overwrite, { spaceId, user }),
authorized: [
createTestDefinitions(normalTypes, false, overwrite, { spaceId }),
createTestDefinitions(hiddenType, true, overwrite, { spaceId }),
createTestDefinitions(normalTypes, false, overwrite, { spaceId, user }),
createTestDefinitions(hiddenType, true, overwrite, { spaceId, user }),
].flat(),
superuser: createTestDefinitions(allTypes, false, overwrite, { spaceId }),
superuser: createTestDefinitions(allTypes, false, overwrite, { spaceId, user }),
};
};
@ -69,7 +79,6 @@ export default function ({ getService }: FtrProviderContext) {
getTestScenarios([false, true]).securityAndSpaces.forEach(
({ spaceId, users, modifier: overwrite }) => {
const suffix = ` within the ${spaceId} space${overwrite ? ' with overwrite enabled' : ''}`;
const { unauthorized, authorized, superuser } = createTests(overwrite!, spaceId);
const _addTests = (user: TestUser, tests: CreateTestDefinition[]) => {
addTests(`${user.description}${suffix}`, { user, spaceId, tests });
};
@ -82,11 +91,14 @@ export default function ({ getService }: FtrProviderContext) {
users.readAtSpace,
users.allAtOtherSpace,
].forEach((user) => {
const { unauthorized } = createTests(overwrite!, spaceId, user);
_addTests(user, unauthorized);
});
[users.dualAll, users.allGlobally, users.allAtSpace].forEach((user) => {
const { authorized } = createTests(overwrite!, spaceId, user);
_addTests(user, authorized);
});
const { superuser } = createTests(overwrite!, spaceId, users.superuser);
_addTests(users.superuser, superuser);
}
);

View file

@ -15,17 +15,23 @@ import {
const createTestCases = (spaceId: string) => {
const cases = getTestCases(spaceId);
const exportableTypes = [
const exportableObjects = [
cases.singleNamespaceObject,
cases.singleNamespaceType,
cases.multiNamespaceObject,
cases.multiNamespaceType,
cases.namespaceAgnosticObject,
];
const exportableTypes = [
cases.singleNamespaceType,
cases.multiNamespaceType,
cases.namespaceAgnosticType,
];
const nonExportableTypes = [cases.hiddenObject, cases.hiddenType];
const allTypes = exportableTypes.concat(nonExportableTypes);
return { exportableTypes, nonExportableTypes, allTypes };
const nonExportableObjectsAndTypes = [cases.hiddenObject, cases.hiddenType];
const allObjectsAndTypes = [
exportableObjects,
exportableTypes,
nonExportableObjectsAndTypes,
].flat();
return { exportableObjects, exportableTypes, nonExportableObjectsAndTypes, allObjectsAndTypes };
};
export default function ({ getService }: FtrProviderContext) {
@ -34,13 +40,19 @@ export default function ({ getService }: FtrProviderContext) {
const { addTests, createTestDefinitions } = exportTestSuiteFactory(esArchiver, supertest);
const createTests = (spaceId: string) => {
const { exportableTypes, nonExportableTypes, allTypes } = createTestCases(spaceId);
const {
exportableObjects,
exportableTypes,
nonExportableObjectsAndTypes,
allObjectsAndTypes,
} = createTestCases(spaceId);
return {
unauthorized: [
createTestDefinitions(exportableTypes, true),
createTestDefinitions(nonExportableTypes, false),
createTestDefinitions(exportableObjects, { statusCode: 403, reason: 'unauthorized' }),
createTestDefinitions(exportableTypes, { statusCode: 200, reason: 'unauthorized' }), // failure with empty result
createTestDefinitions(nonExportableObjectsAndTypes, false),
].flat(),
authorized: createTestDefinitions(allTypes, false),
authorized: createTestDefinitions(allObjectsAndTypes, false),
};
};

View file

@ -4,18 +4,30 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { getTestScenarios } from '../../common/lib/saved_object_test_utils';
import { SPACES } from '../../common/lib/spaces';
import { AUTHENTICATION } from '../../common/lib/authentication';
import {
getTestScenarios,
isUserAuthorizedAtSpace,
} from '../../common/lib/saved_object_test_utils';
import { TestUser } from '../../common/lib/types';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import { findTestSuiteFactory, getTestCases } from '../../common/suites/find';
const createTestCases = (currentSpace: string, crossSpaceSearch: string[]) => {
const {
DEFAULT: { spaceId: DEFAULT_SPACE_ID },
SPACE_1: { spaceId: SPACE_1_ID },
SPACE_2: { spaceId: SPACE_2_ID },
} = SPACES;
const createTestCases = (currentSpace: string, crossSpaceSearch?: string[]) => {
const cases = getTestCases({ currentSpace, crossSpaceSearch });
const normalTypes = [
cases.singleNamespaceType,
cases.multiNamespaceType,
cases.namespaceAgnosticType,
cases.eachType,
cases.pageBeyondTotal,
cases.unknownSearchField,
cases.filterWithNamespaceAgnosticType,
@ -37,89 +49,72 @@ export default function ({ getService }: FtrProviderContext) {
const { addTests, createTestDefinitions } = findTestSuiteFactory(esArchiver, supertest);
const createTests = (spaceId: string, user: TestUser) => {
const currentSpaceCases = createTestCases(spaceId, []);
const currentSpaceCases = createTestCases(spaceId);
const explicitCrossSpace = createTestCases(spaceId, ['default', 'space_1', 'space_2']);
const EACH_SPACE = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID];
const explicitCrossSpace = createTestCases(spaceId, EACH_SPACE);
const wildcardCrossSpace = createTestCases(spaceId, ['*']);
if (user.username === 'elastic') {
if (user.username === AUTHENTICATION.SUPERUSER.username) {
return {
currentSpace: createTestDefinitions(currentSpaceCases.allTypes, false, { user }),
crossSpace: createTestDefinitions(explicitCrossSpace.allTypes, false, { user }),
crossSpace: [
createTestDefinitions(explicitCrossSpace.allTypes, false, { user }),
createTestDefinitions(wildcardCrossSpace.allTypes, false, { user }),
].flat(),
};
}
const authorizedAtCurrentSpace =
user.authorizedAtSpaces.includes(spaceId) || user.authorizedAtSpaces.includes('*');
const authorizedExplicitCrossSpaces = ['default', 'space_1', 'space_2'].filter(
(s) =>
user.authorizedAtSpaces.includes('*') ||
(s !== spaceId && user.authorizedAtSpaces.includes(s))
const isAuthorizedExplicitCrossSpaces = EACH_SPACE.some(
(s) => s !== spaceId && isUserAuthorizedAtSpace(user, s)
);
const isAuthorizedWildcardCrossSpaces = EACH_SPACE.some((s) =>
isUserAuthorizedAtSpace(user, s)
);
const authorizedWildcardCrossSpaces = ['default', 'space_1', 'space_2'].filter(
(s) => user.authorizedAtSpaces.includes('*') || user.authorizedAtSpaces.includes(s)
);
const explicitCrossSpaceDefinitions =
authorizedExplicitCrossSpaces.length > 0
? [
createTestDefinitions(explicitCrossSpace.normalTypes, false, { user }),
createTestDefinitions(
explicitCrossSpace.hiddenAndUnknownTypes,
{
statusCode: 403,
reason: 'forbidden_types',
},
{ user }
),
].flat()
: createTestDefinitions(
explicitCrossSpace.allTypes,
{
statusCode: 403,
reason: 'forbidden_namespaces',
},
const explicitCrossSpaceDefinitions = isAuthorizedExplicitCrossSpaces
? [
createTestDefinitions(explicitCrossSpace.normalTypes, false, { user }),
createTestDefinitions(
explicitCrossSpace.hiddenAndUnknownTypes,
{ statusCode: 200, reason: 'unauthorized' },
{ user }
);
const wildcardCrossSpaceDefinitions =
authorizedWildcardCrossSpaces.length > 0
? [
createTestDefinitions(wildcardCrossSpace.normalTypes, false, { user }),
createTestDefinitions(
wildcardCrossSpace.hiddenAndUnknownTypes,
{
statusCode: 403,
reason: 'forbidden_types',
},
{ user }
),
].flat()
: createTestDefinitions(
wildcardCrossSpace.allTypes,
{
statusCode: 403,
reason: 'forbidden_namespaces',
},
),
].flat()
: createTestDefinitions(
explicitCrossSpace.allTypes,
{ statusCode: 200, reason: 'unauthorized' },
{ user }
);
const wildcardCrossSpaceDefinitions = isAuthorizedWildcardCrossSpaces
? [
createTestDefinitions(wildcardCrossSpace.normalTypes, false, { user }),
createTestDefinitions(
wildcardCrossSpace.hiddenAndUnknownTypes,
{ statusCode: 200, reason: 'unauthorized' },
{ user }
);
),
].flat()
: createTestDefinitions(
wildcardCrossSpace.allTypes,
{ statusCode: 200, reason: 'unauthorized' },
{ user }
);
return {
currentSpace: authorizedAtCurrentSpace
currentSpace: isUserAuthorizedAtSpace(user, spaceId)
? [
createTestDefinitions(currentSpaceCases.normalTypes, false, {
user,
}),
createTestDefinitions(currentSpaceCases.hiddenAndUnknownTypes, {
statusCode: 403,
reason: 'forbidden_types',
statusCode: 200,
reason: 'unauthorized',
}),
].flat()
: createTestDefinitions(currentSpaceCases.allTypes, {
statusCode: 403,
reason: 'forbidden_types',
statusCode: 200,
reason: 'unauthorized',
}),
crossSpace: [...explicitCrossSpaceDefinitions, ...wildcardCrossSpaceDefinitions],
};

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { SPACES } from '../../common/lib/spaces';
import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils';
import { TestUser } from '../../common/lib/types';
import { FtrProviderContext } from '../../common/ftr_provider_context';
@ -13,22 +14,26 @@ import {
BulkCreateTestDefinition,
} from '../../common/suites/bulk_create';
const {
DEFAULT: { spaceId: DEFAULT_SPACE_ID },
} = SPACES;
const { fail400, fail409 } = testCaseFailures;
const unresolvableConflict = () => ({ fail409Param: 'unresolvableConflict' });
const createTestCases = (overwrite: boolean) => {
// for each permitted (non-403) outcome, if failure !== undefined then we expect
// to receive an error; otherwise, we expect to receive a success result
const expectedNamespaces = [DEFAULT_SPACE_ID]; // newly created objects should have this `namespaces` array in their return value
const normalTypes = [
{ ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(!overwrite) },
CASES.SINGLE_NAMESPACE_SPACE_1,
CASES.SINGLE_NAMESPACE_SPACE_2,
{ ...CASES.SINGLE_NAMESPACE_SPACE_1, expectedNamespaces },
{ ...CASES.SINGLE_NAMESPACE_SPACE_2, expectedNamespaces },
{ ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) },
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(), ...unresolvableConflict() },
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(), ...unresolvableConflict() },
{ ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) },
CASES.NEW_SINGLE_NAMESPACE_OBJ,
CASES.NEW_MULTI_NAMESPACE_OBJ,
{ ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces },
{ ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces },
CASES.NEW_NAMESPACE_AGNOSTIC_OBJ,
];
const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }];
@ -46,27 +51,27 @@ export default function ({ getService }: FtrProviderContext) {
esArchiver,
supertest
);
const createTests = (overwrite: boolean) => {
const createTests = (overwrite: boolean, user: TestUser) => {
const { normalTypes, hiddenType, allTypes } = createTestCases(overwrite);
// use singleRequest to reduce execution time and/or test combined cases
return {
unauthorized: createTestDefinitions(allTypes, true, overwrite),
unauthorized: createTestDefinitions(allTypes, true, overwrite, { user }),
authorized: [
createTestDefinitions(normalTypes, false, overwrite, { singleRequest: true }),
createTestDefinitions(hiddenType, true, overwrite),
createTestDefinitions(normalTypes, false, overwrite, { user, singleRequest: true }),
createTestDefinitions(hiddenType, true, overwrite, { user }),
createTestDefinitions(allTypes, true, overwrite, {
user,
singleRequest: true,
responseBodyOverride: expectForbidden(['hiddentype']),
}),
].flat(),
superuser: createTestDefinitions(allTypes, false, overwrite, { singleRequest: true }),
superuser: createTestDefinitions(allTypes, false, overwrite, { user, singleRequest: true }),
};
};
describe('_bulk_create', () => {
getTestScenarios([false, true]).security.forEach(({ users, modifier: overwrite }) => {
const suffix = overwrite ? ' with overwrite enabled' : '';
const { unauthorized, authorized, superuser } = createTests(overwrite!);
const _addTests = (user: TestUser, tests: BulkCreateTestDefinition[]) => {
addTests(`${user.description}${suffix}`, { user, tests });
};
@ -81,11 +86,14 @@ export default function ({ getService }: FtrProviderContext) {
users.allAtSpace1,
users.readAtSpace1,
].forEach((user) => {
const { unauthorized } = createTests(overwrite!, user);
_addTests(user, unauthorized);
});
[users.dualAll, users.allGlobally].forEach((user) => {
const { authorized } = createTests(overwrite!, user);
_addTests(user, authorized);
});
const { superuser } = createTests(overwrite!, users.superuser);
_addTests(users.superuser, superuser);
});
});

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { SPACES } from '../../common/lib/spaces';
import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils';
import { TestUser } from '../../common/lib/types';
import { FtrProviderContext } from '../../common/ftr_provider_context';
@ -13,21 +14,25 @@ import {
CreateTestDefinition,
} from '../../common/suites/create';
const {
DEFAULT: { spaceId: DEFAULT_SPACE_ID },
} = SPACES;
const { fail400, fail409 } = testCaseFailures;
const createTestCases = (overwrite: boolean) => {
// for each permitted (non-403) outcome, if failure !== undefined then we expect
// to receive an error; otherwise, we expect to receive a success result
const expectedNamespaces = [DEFAULT_SPACE_ID]; // newly created objects should have this `namespaces` array in their return value
const normalTypes = [
{ ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(!overwrite) },
CASES.SINGLE_NAMESPACE_SPACE_1,
CASES.SINGLE_NAMESPACE_SPACE_2,
{ ...CASES.SINGLE_NAMESPACE_SPACE_1, expectedNamespaces },
{ ...CASES.SINGLE_NAMESPACE_SPACE_2, expectedNamespaces },
{ ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) },
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409() },
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409() },
{ ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) },
CASES.NEW_SINGLE_NAMESPACE_OBJ,
CASES.NEW_MULTI_NAMESPACE_OBJ,
{ ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces },
{ ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces },
CASES.NEW_NAMESPACE_AGNOSTIC_OBJ,
];
const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }];
@ -41,22 +46,21 @@ export default function ({ getService }: FtrProviderContext) {
const es = getService('legacyEs');
const { addTests, createTestDefinitions } = createTestSuiteFactory(es, esArchiver, supertest);
const createTests = (overwrite: boolean) => {
const createTests = (overwrite: boolean, user: TestUser) => {
const { normalTypes, hiddenType, allTypes } = createTestCases(overwrite);
return {
unauthorized: createTestDefinitions(allTypes, true, overwrite),
unauthorized: createTestDefinitions(allTypes, true, overwrite, { user }),
authorized: [
createTestDefinitions(normalTypes, false, overwrite),
createTestDefinitions(hiddenType, true, overwrite),
createTestDefinitions(normalTypes, false, overwrite, { user }),
createTestDefinitions(hiddenType, true, overwrite, { user }),
].flat(),
superuser: createTestDefinitions(allTypes, false, overwrite),
superuser: createTestDefinitions(allTypes, false, overwrite, { user }),
};
};
describe('_create', () => {
getTestScenarios([false, true]).security.forEach(({ users, modifier: overwrite }) => {
const suffix = overwrite ? ' with overwrite enabled' : '';
const { unauthorized, authorized, superuser } = createTests(overwrite!);
const _addTests = (user: TestUser, tests: CreateTestDefinition[]) => {
addTests(`${user.description}${suffix}`, { user, tests });
};
@ -71,11 +75,14 @@ export default function ({ getService }: FtrProviderContext) {
users.allAtSpace1,
users.readAtSpace1,
].forEach((user) => {
const { unauthorized } = createTests(overwrite!, user);
_addTests(user, unauthorized);
});
[users.dualAll, users.allGlobally].forEach((user) => {
const { authorized } = createTests(overwrite!, user);
_addTests(user, authorized);
});
const { superuser } = createTests(overwrite!, users.superuser);
_addTests(users.superuser, superuser);
});
});

View file

@ -15,17 +15,23 @@ import {
const createTestCases = () => {
const cases = getTestCases();
const exportableTypes = [
const exportableObjects = [
cases.singleNamespaceObject,
cases.singleNamespaceType,
cases.multiNamespaceObject,
cases.multiNamespaceType,
cases.namespaceAgnosticObject,
];
const exportableTypes = [
cases.singleNamespaceType,
cases.multiNamespaceType,
cases.namespaceAgnosticType,
];
const nonExportableTypes = [cases.hiddenObject, cases.hiddenType];
const allTypes = exportableTypes.concat(nonExportableTypes);
return { exportableTypes, nonExportableTypes, allTypes };
const nonExportableObjectsAndTypes = [cases.hiddenObject, cases.hiddenType];
const allObjectsAndTypes = [
exportableObjects,
exportableTypes,
nonExportableObjectsAndTypes,
].flat();
return { exportableObjects, exportableTypes, nonExportableObjectsAndTypes, allObjectsAndTypes };
};
export default function ({ getService }: FtrProviderContext) {
@ -34,13 +40,19 @@ export default function ({ getService }: FtrProviderContext) {
const { addTests, createTestDefinitions } = exportTestSuiteFactory(esArchiver, supertest);
const createTests = () => {
const { exportableTypes, nonExportableTypes, allTypes } = createTestCases();
const {
exportableObjects,
exportableTypes,
nonExportableObjectsAndTypes,
allObjectsAndTypes,
} = createTestCases();
return {
unauthorized: [
createTestDefinitions(exportableTypes, true),
createTestDefinitions(nonExportableTypes, false),
createTestDefinitions(exportableObjects, { statusCode: 403, reason: 'unauthorized' }),
createTestDefinitions(exportableTypes, { statusCode: 200, reason: 'unauthorized' }), // failure with empty result
createTestDefinitions(nonExportableObjectsAndTypes, false),
].flat(),
authorized: createTestDefinitions(allTypes, false),
authorized: createTestDefinitions(allObjectsAndTypes, false),
};
};

View file

@ -4,18 +4,27 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { SPACES } from '../../common/lib/spaces';
import { AUTHENTICATION } from '../../common/lib/authentication';
import { getTestScenarios } from '../../common/lib/saved_object_test_utils';
import { TestUser } from '../../common/lib/types';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import { findTestSuiteFactory, getTestCases } from '../../common/suites/find';
const createTestCases = (crossSpaceSearch: string[]) => {
const {
DEFAULT: { spaceId: DEFAULT_SPACE_ID },
SPACE_1: { spaceId: SPACE_1_ID },
SPACE_2: { spaceId: SPACE_2_ID },
} = SPACES;
const createTestCases = (crossSpaceSearch?: string[]) => {
const cases = getTestCases({ crossSpaceSearch });
const normalTypes = [
cases.singleNamespaceType,
cases.multiNamespaceType,
cases.namespaceAgnosticType,
cases.eachType,
cases.pageBeyondTotal,
cases.unknownSearchField,
cases.filterWithNamespaceAgnosticType,
@ -37,46 +46,35 @@ export default function ({ getService }: FtrProviderContext) {
const { addTests, createTestDefinitions } = findTestSuiteFactory(esArchiver, supertest);
const createTests = (user: TestUser) => {
const defaultCases = createTestCases([]);
const crossSpaceCases = createTestCases(['default', 'space_1', 'space_2']);
const defaultCases = createTestCases();
const crossSpaceCases = createTestCases([DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]);
if (user.username === 'elastic') {
if (user.username === AUTHENTICATION.SUPERUSER.username) {
return {
defaultCases: createTestDefinitions(defaultCases.allTypes, false, { user }),
crossSpace: createTestDefinitions(
crossSpaceCases.allTypes,
{
statusCode: 400,
reason: 'cross_namespace_not_permitted',
},
{ statusCode: 400, reason: 'cross_namespace_not_permitted' },
{ user }
),
};
}
const authorizedGlobally = user.authorizedAtSpaces.includes('*');
const isAuthorizedGlobally = user.authorizedAtSpaces.includes('*');
return {
defaultCases: authorizedGlobally
defaultCases: isAuthorizedGlobally
? [
createTestDefinitions(defaultCases.normalTypes, false, {
user,
}),
createTestDefinitions(defaultCases.normalTypes, false, { user }),
createTestDefinitions(defaultCases.hiddenAndUnknownTypes, {
statusCode: 403,
reason: 'forbidden_types',
statusCode: 200,
reason: 'unauthorized',
}),
].flat()
: createTestDefinitions(defaultCases.allTypes, {
statusCode: 403,
reason: 'forbidden_types',
}),
: createTestDefinitions(defaultCases.allTypes, { statusCode: 200, reason: 'unauthorized' }),
crossSpace: createTestDefinitions(
crossSpaceCases.allTypes,
{
statusCode: 400,
reason: 'cross_namespace_not_permitted',
},
{ statusCode: 400, reason: 'cross_namespace_not_permitted' },
{ user }
),
};

View file

@ -19,36 +19,48 @@ const { fail400, fail409 } = testCaseFailures;
const unresolvableConflict = (condition?: boolean) =>
condition !== false ? { fail409Param: 'unresolvableConflict' } : {};
const createTestCases = (overwrite: boolean, spaceId: string) => [
const createTestCases = (overwrite: boolean, spaceId: string) => {
// for each outcome, if failure !== undefined then we expect to receive
// an error; otherwise, we expect to receive a success result
{
...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE,
...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID),
},
{ ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) },
{ ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) },
{
...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1,
...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)),
...unresolvableConflict(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID),
},
{
...CASES.MULTI_NAMESPACE_ONLY_SPACE_1,
...fail409(!overwrite || spaceId !== SPACE_1_ID),
...unresolvableConflict(spaceId !== SPACE_1_ID),
},
{
...CASES.MULTI_NAMESPACE_ONLY_SPACE_2,
...fail409(!overwrite || spaceId !== SPACE_2_ID),
...unresolvableConflict(spaceId !== SPACE_2_ID),
},
{ ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) },
{ ...CASES.HIDDEN, ...fail400() },
CASES.NEW_SINGLE_NAMESPACE_OBJ,
CASES.NEW_MULTI_NAMESPACE_OBJ,
CASES.NEW_NAMESPACE_AGNOSTIC_OBJ,
];
const expectedNamespaces = [spaceId]; // newly created objects should have this `namespaces` array in their return value
return [
{
...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE,
...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID),
expectedNamespaces,
},
{
...CASES.SINGLE_NAMESPACE_SPACE_1,
...fail409(!overwrite && spaceId === SPACE_1_ID),
expectedNamespaces,
},
{
...CASES.SINGLE_NAMESPACE_SPACE_2,
...fail409(!overwrite && spaceId === SPACE_2_ID),
expectedNamespaces,
},
{
...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1,
...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)),
...unresolvableConflict(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID),
},
{
...CASES.MULTI_NAMESPACE_ONLY_SPACE_1,
...fail409(!overwrite || spaceId !== SPACE_1_ID),
...unresolvableConflict(spaceId !== SPACE_1_ID),
},
{
...CASES.MULTI_NAMESPACE_ONLY_SPACE_2,
...fail409(!overwrite || spaceId !== SPACE_2_ID),
...unresolvableConflict(spaceId !== SPACE_2_ID),
},
{ ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) },
{ ...CASES.HIDDEN, ...fail400() },
{ ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces },
{ ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces },
CASES.NEW_NAMESPACE_AGNOSTIC_OBJ,
];
};
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');

View file

@ -16,27 +16,39 @@ const {
} = SPACES;
const { fail400, fail409 } = testCaseFailures;
const createTestCases = (overwrite: boolean, spaceId: string) => [
const createTestCases = (overwrite: boolean, spaceId: string) => {
// for each outcome, if failure !== undefined then we expect to receive
// an error; otherwise, we expect to receive a success result
{
...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE,
...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID),
},
{ ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) },
{ ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) },
{
...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1,
...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)),
},
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) },
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) },
{ ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) },
{ ...CASES.HIDDEN, ...fail400() },
CASES.NEW_SINGLE_NAMESPACE_OBJ,
CASES.NEW_MULTI_NAMESPACE_OBJ,
CASES.NEW_NAMESPACE_AGNOSTIC_OBJ,
];
const expectedNamespaces = [spaceId]; // newly created objects should have this `namespaces` array in their return value
return [
{
...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE,
...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID),
expectedNamespaces,
},
{
...CASES.SINGLE_NAMESPACE_SPACE_1,
...fail409(!overwrite && spaceId === SPACE_1_ID),
expectedNamespaces,
},
{
...CASES.SINGLE_NAMESPACE_SPACE_2,
...fail409(!overwrite && spaceId === SPACE_2_ID),
expectedNamespaces,
},
{
...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1,
...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)),
},
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) },
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) },
{ ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) },
{ ...CASES.HIDDEN, ...fail400() },
{ ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces },
{ ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces },
CASES.NEW_NAMESPACE_AGNOSTIC_OBJ,
];
};
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertestWithoutAuth');

View file

@ -4,11 +4,18 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { SPACES } from '../../common/lib/spaces';
import { getTestScenarios } from '../../common/lib/saved_object_test_utils';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import { findTestSuiteFactory, getTestCases } from '../../common/suites/find';
const createTestCases = (spaceId: string, crossSpaceSearch: string[]) => {
const {
DEFAULT: { spaceId: DEFAULT_SPACE_ID },
SPACE_1: { spaceId: SPACE_1_ID },
SPACE_2: { spaceId: SPACE_2_ID },
} = SPACES;
const createTestCases = (spaceId: string, crossSpaceSearch?: string[]) => {
const cases = getTestCases({ currentSpace: spaceId, crossSpaceSearch });
return Object.values(cases);
};
@ -18,15 +25,19 @@ export default function ({ getService }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const { addTests, createTestDefinitions } = findTestSuiteFactory(esArchiver, supertest);
const createTests = (spaceId: string, crossSpaceSearch: string[]) => {
const createTests = (spaceId: string, crossSpaceSearch?: string[]) => {
const testCases = createTestCases(spaceId, crossSpaceSearch);
return createTestDefinitions(testCases, false);
};
describe('_find', () => {
getTestScenarios().spaces.forEach(({ spaceId }) => {
const currentSpaceTests = createTests(spaceId, []);
const explicitCrossSpaceTests = createTests(spaceId, ['default', 'space_1', 'space_2']);
const currentSpaceTests = createTests(spaceId);
const explicitCrossSpaceTests = createTests(spaceId, [
DEFAULT_SPACE_ID,
SPACE_1_ID,
SPACE_2_ID,
]);
const wildcardCrossSpaceTests = createTests(spaceId, ['*']);
addTests(`within the ${spaceId} space`, {
spaceId,