Skip to content

Commit 4525f0c

Browse files
legregoazasypkinelasticmachine
authored
Omit runtime fields from FLS suggestions (#78330)
Co-authored-by: Aleh Zasypkin <aleh.zasypkin@gmail.com> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
1 parent d793040 commit 4525f0c

File tree

4 files changed

+160
-9
lines changed

4 files changed

+160
-9
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import { httpServerMock, elasticsearchServiceMock } from '../../../../../../src/core/server/mocks';
8+
import { kibanaResponseFactory } from '../../../../../../src/core/server';
9+
10+
import { routeDefinitionParamsMock } from '../index.mock';
11+
import { defineGetFieldsRoutes } from './get_fields';
12+
13+
const createFieldMapping = (field: string, type: string) => ({
14+
[field]: { mapping: { [field]: { type } } },
15+
});
16+
17+
const createEmptyFieldMapping = (field: string) => ({ [field]: { mapping: {} } });
18+
19+
const mockFieldMappingResponse = {
20+
foo: {
21+
mappings: {
22+
...createFieldMapping('fooField', 'keyword'),
23+
...createFieldMapping('commonField', 'keyword'),
24+
...createEmptyFieldMapping('emptyField'),
25+
},
26+
},
27+
bar: {
28+
mappings: {
29+
...createFieldMapping('commonField', 'keyword'),
30+
...createFieldMapping('barField', 'keyword'),
31+
...createFieldMapping('runtimeField', 'runtime'),
32+
},
33+
},
34+
};
35+
36+
describe('GET /internal/security/fields/{query}', () => {
37+
it('returns a list of deduplicated fields, omitting empty and runtime fields', async () => {
38+
const mockRouteDefinitionParams = routeDefinitionParamsMock.create();
39+
40+
const scopedClient = elasticsearchServiceMock.createLegacyScopedClusterClient();
41+
scopedClient.callAsCurrentUser.mockResolvedValue(mockFieldMappingResponse);
42+
mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(scopedClient);
43+
44+
defineGetFieldsRoutes(mockRouteDefinitionParams);
45+
46+
const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls;
47+
48+
const headers = { authorization: 'foo' };
49+
const mockRequest = httpServerMock.createKibanaRequest({
50+
method: 'get',
51+
path: `/internal/security/fields/foo`,
52+
headers,
53+
});
54+
const response = await handler({} as any, mockRequest, kibanaResponseFactory);
55+
expect(response.status).toBe(200);
56+
expect(response.payload).toEqual(['fooField', 'commonField', 'barField']);
57+
});
58+
});

x-pack/plugins/security/server/routes/indices/get_fields.ts

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,20 @@ import { schema } from '@kbn/config-schema';
88
import { RouteDefinitionParams } from '../index';
99
import { wrapIntoCustomErrorResponse } from '../../errors';
1010

11+
interface FieldMappingResponse {
12+
[indexName: string]: {
13+
mappings: {
14+
[fieldName: string]: {
15+
mapping: {
16+
[fieldName: string]: {
17+
type: string;
18+
};
19+
};
20+
};
21+
};
22+
};
23+
}
24+
1125
export function defineGetFieldsRoutes({ router, clusterClient }: RouteDefinitionParams) {
1226
router.get(
1327
{
@@ -23,21 +37,35 @@ export function defineGetFieldsRoutes({ router, clusterClient }: RouteDefinition
2337
fields: '*',
2438
allowNoIndices: false,
2539
includeDefaults: true,
26-
})) as Record<string, { mappings: Record<string, unknown> }>;
40+
})) as FieldMappingResponse;
2741

2842
// The flow is the following (see response format at https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-get-field-mapping.html):
2943
// 1. Iterate over all matched indices.
3044
// 2. Extract all the field names from the `mappings` field of the particular index.
31-
// 3. Collect and flatten the list of the field names.
45+
// 3. Collect and flatten the list of the field names, omitting any fields without mappings, and any runtime fields
3246
// 4. Use `Set` to get only unique field names.
47+
const fields = Array.from(
48+
new Set(
49+
Object.values(indexMappings).flatMap((indexMapping) => {
50+
return Object.keys(indexMapping.mappings).filter((fieldName) => {
51+
const mappingValues = Object.values(indexMapping.mappings[fieldName].mapping);
52+
const hasMapping = mappingValues.length > 0;
53+
54+
const isRuntimeField = hasMapping && mappingValues[0]?.type === 'runtime';
55+
56+
// fields without mappings are internal fields such as `_routing` and `_index`,
57+
// and therefore don't make sense as autocomplete suggestions for FLS.
58+
59+
// Runtime fields are not securable via FLS.
60+
// Administrators should instead secure access to the fields which derive this information.
61+
return hasMapping && !isRuntimeField;
62+
});
63+
})
64+
)
65+
);
66+
3367
return response.ok({
34-
body: Array.from(
35-
new Set(
36-
Object.values(indexMappings)
37-
.map((indexMapping) => Object.keys(indexMapping.mappings))
38-
.flat()
39-
)
40-
),
68+
body: fields,
4169
});
4270
} catch (error) {
4371
return response.customError(wrapIntoCustomErrorResponse(error));

x-pack/test/api_integration/apis/security/index_fields.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,33 @@
77
import expect from '@kbn/expect/expect.js';
88
import { FtrProviderContext } from '../../ftr_provider_context';
99

10+
interface FLSFieldMappingResponse {
11+
flstest: {
12+
mappings: {
13+
[fieldName: string]: {
14+
mapping: {
15+
[fieldName: string]: {
16+
type: string;
17+
};
18+
};
19+
};
20+
};
21+
};
22+
}
23+
1024
export default function ({ getService }: FtrProviderContext) {
1125
const supertest = getService('supertest');
26+
const esArchiver = getService('esArchiver');
27+
const es = getService('legacyEs');
1228

1329
describe('Index Fields', () => {
30+
before(async () => {
31+
await esArchiver.load('security/flstest/data');
32+
});
33+
after(async () => {
34+
await esArchiver.unload('security/flstest/data');
35+
});
36+
1437
describe('GET /internal/security/fields/{query}', () => {
1538
it('should return a list of available index mapping fields', async () => {
1639
await supertest
@@ -30,6 +53,41 @@ export default function ({ getService }: FtrProviderContext) {
3053
sampleOfExpectedFields.forEach((field) => expect(response.body).to.contain(field));
3154
});
3255
});
56+
57+
it('should not include runtime fields', async () => {
58+
// First, make sure the mapping actually includes a runtime field
59+
const fieldMapping = (await es.indices.getFieldMapping({
60+
index: 'flstest',
61+
fields: '*',
62+
includeDefaults: true,
63+
})) as FLSFieldMappingResponse;
64+
65+
expect(Object.keys(fieldMapping.flstest.mappings)).to.contain('runtime_customer_ssn');
66+
expect(
67+
fieldMapping.flstest.mappings.runtime_customer_ssn.mapping.runtime_customer_ssn.type
68+
).to.eql('runtime');
69+
70+
// Now, make sure it's not returned here
71+
const { body: actualFields } = (await supertest
72+
.get('/internal/security/fields/flstest')
73+
.set('kbn-xsrf', 'xxx')
74+
.send()
75+
.expect(200)) as { body: string[] };
76+
77+
const expectedFields = [
78+
'customer_ssn',
79+
'customer_ssn.keyword',
80+
'customer_region',
81+
'customer_region.keyword',
82+
'customer_name',
83+
'customer_name.keyword',
84+
];
85+
86+
actualFields.sort();
87+
expectedFields.sort();
88+
89+
expect(actualFields).to.eql(expectedFields);
90+
});
3391
});
3492
});
3593
}

x-pack/test/functional/es_archives/security/flstest/data/mappings.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@
3030
}
3131
},
3232
"type": "text"
33+
},
34+
"runtime_customer_ssn": {
35+
"type": "runtime",
36+
"runtime_type": "keyword",
37+
"script": {
38+
"source": "emit(doc['customer_ssn'].value + ' calculated at runtime')"
39+
}
3340
}
3441
}
3542
},

0 commit comments

Comments
 (0)