Skip to content

Commit

Permalink
Security usage data (#110548)
Browse files Browse the repository at this point in the history
# Conflicts:
#	x-pack/plugins/security/server/config.test.ts
#	x-pack/plugins/security/server/config.ts
  • Loading branch information
jportner committed Sep 1, 2021
1 parent 6549f31 commit e187d7d
Show file tree
Hide file tree
Showing 12 changed files with 332 additions and 115 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const createStartContractMock = () => {
keystoreConfigured: false,
truststoreConfigured: false,
},
principal: 'unknown',
},
http: {
basePathConfigured: false,
Expand Down
106 changes: 85 additions & 21 deletions src/core/server/core_usage_data/core_usage_data_service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* Side Public License, v 1.
*/

import type { ConfigPath } from '@kbn/config';
import { BehaviorSubject, Observable } from 'rxjs';
import { HotObservable } from 'rxjs/internal/testing/HotObservable';
import { TestScheduler } from 'rxjs/testing';
Expand All @@ -29,12 +30,31 @@ import { CORE_USAGE_STATS_TYPE } from './constants';
import { CoreUsageStatsClient } from './core_usage_stats_client';

describe('CoreUsageDataService', () => {
function getConfigServiceAtPathMockImplementation() {
return (path: ConfigPath) => {
if (path === 'elasticsearch') {
return new BehaviorSubject(RawElasticsearchConfig.schema.validate({}));
} else if (path === 'server') {
return new BehaviorSubject(RawHttpConfig.schema.validate({}));
} else if (path === 'logging') {
return new BehaviorSubject(RawLoggingConfig.schema.validate({}));
} else if (path === 'savedObjects') {
return new BehaviorSubject(RawSavedObjectsConfig.schema.validate({}));
} else if (path === 'kibana') {
return new BehaviorSubject(RawKibanaConfig.schema.validate({}));
}
return new BehaviorSubject({});
};
}

const getTestScheduler = () =>
new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});

let service: CoreUsageDataService;
let configService: ReturnType<typeof configServiceMock.create>;

const mockConfig = {
unused_config: {},
elasticsearch: { username: 'kibana_system', password: 'changeme' },
Expand All @@ -60,27 +80,11 @@ describe('CoreUsageDataService', () => {
},
};

const configService = configServiceMock.create({
getConfig$: mockConfig,
});

configService.atPath.mockImplementation((path) => {
if (path === 'elasticsearch') {
return new BehaviorSubject(RawElasticsearchConfig.schema.validate({}));
} else if (path === 'server') {
return new BehaviorSubject(RawHttpConfig.schema.validate({}));
} else if (path === 'logging') {
return new BehaviorSubject(RawLoggingConfig.schema.validate({}));
} else if (path === 'savedObjects') {
return new BehaviorSubject(RawSavedObjectsConfig.schema.validate({}));
} else if (path === 'kibana') {
return new BehaviorSubject(RawKibanaConfig.schema.validate({}));
}
return new BehaviorSubject({});
});
const coreContext = mockCoreContext.create({ configService });

beforeEach(() => {
configService = configServiceMock.create({ getConfig$: mockConfig });
configService.atPath.mockImplementation(getConfigServiceAtPathMockImplementation());

const coreContext = mockCoreContext.create({ configService });
service = new CoreUsageDataService(coreContext);
});

Expand Down Expand Up @@ -150,7 +154,7 @@ describe('CoreUsageDataService', () => {

describe('start', () => {
describe('getCoreUsageData', () => {
it('returns core metrics for default config', async () => {
function setup() {
const http = httpServiceMock.createInternalSetupContract();
const metrics = metricsServiceMock.createInternalSetupContract();
const savedObjectsStartPromise = Promise.resolve(
Expand Down Expand Up @@ -208,6 +212,11 @@ describe('CoreUsageDataService', () => {
exposedConfigsToUsage: new Map(),
elasticsearch,
});
return { getCoreUsageData };
}

it('returns core metrics for default config', async () => {
const { getCoreUsageData } = setup();
expect(getCoreUsageData()).resolves.toMatchInlineSnapshot(`
Object {
"config": Object {
Expand All @@ -226,6 +235,7 @@ describe('CoreUsageDataService', () => {
"logQueries": false,
"numberOfHostsConfigured": 1,
"pingTimeoutMs": 30000,
"principal": "unknown",
"requestHeadersWhitelistConfigured": false,
"requestTimeoutMs": 30000,
"shardTimeoutMs": 30000,
Expand Down Expand Up @@ -354,6 +364,60 @@ describe('CoreUsageDataService', () => {
}
`);
});

describe('elasticsearch.principal', () => {
async function doTest({
username,
serviceAccountToken,
expectedPrincipal,
}: {
username?: string;
serviceAccountToken?: string;
expectedPrincipal: string;
}) {
const defaultMockImplementation = getConfigServiceAtPathMockImplementation();
configService.atPath.mockImplementation((path) => {
if (path === 'elasticsearch') {
return new BehaviorSubject(
RawElasticsearchConfig.schema.validate({ username, serviceAccountToken })
);
}
return defaultMockImplementation(path);
});
const { getCoreUsageData } = setup();
return expect(getCoreUsageData()).resolves.toEqual(
expect.objectContaining({
config: expect.objectContaining({
elasticsearch: expect.objectContaining({ principal: expectedPrincipal }),
}),
})
);
}

it('returns expected usage data for elastic.username "elastic"', async () => {
return doTest({ username: 'elastic', expectedPrincipal: 'elastic_user' });
});

it('returns expected usage data for elastic.username "kibana"', async () => {
return doTest({ username: 'kibana', expectedPrincipal: 'kibana_user' });
});

it('returns expected usage data for elastic.username "kibana_system"', async () => {
return doTest({ username: 'kibana_system', expectedPrincipal: 'kibana_system_user' });
});

it('returns expected usage data for elastic.username anything else', async () => {
return doTest({ username: 'anything else', expectedPrincipal: 'other_user' });
});

it('returns expected usage data for elastic.serviceAccountToken', async () => {
// Note: elastic.username and elastic.serviceAccountToken are mutually exclusive
return doTest({
serviceAccountToken: 'any',
expectedPrincipal: 'kibana_service_account',
});
});
});
});

describe('getConfigsUsageData', () => {
Expand Down
21 changes: 21 additions & 0 deletions src/core/server/core_usage_data/core_usage_data_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type {
CoreUsageDataStart,
CoreUsageDataSetup,
ConfigUsageData,
CoreConfigUsageData,
} from './types';
import { isConfigured } from './is_configured';
import { ElasticsearchServiceStart } from '../elasticsearch';
Expand Down Expand Up @@ -253,6 +254,7 @@ export class CoreUsageDataService implements CoreService<CoreUsageDataSetup, Cor
truststoreConfigured: isConfigured.record(es.ssl.truststore),
keystoreConfigured: isConfigured.record(es.ssl.keystore),
},
principal: getEsPrincipalUsage(es),
},
http: {
basePathConfigured: isConfigured.string(http.basePath),
Expand Down Expand Up @@ -512,3 +514,22 @@ export class CoreUsageDataService implements CoreService<CoreUsageDataSetup, Cor
this.stop$.complete();
}
}

function getEsPrincipalUsage({ username, serviceAccountToken }: ElasticsearchConfigType) {
let value: CoreConfigUsageData['elasticsearch']['principal'] = 'unknown';
if (isConfigured.string(username)) {
switch (username) {
case 'elastic': // deprecated
case 'kibana': // deprecated
case 'kibana_system':
value = `${username}_user` as const;
break;
default:
value = 'other_user';
}
} else if (serviceAccountToken) {
// cannot be used with elasticsearch.username
value = 'kibana_service_account';
}
return value;
}
7 changes: 7 additions & 0 deletions src/core/server/core_usage_data/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,13 @@ export interface CoreConfigUsageData {
};
apiVersion: string;
healthCheckDelayMs: number;
principal:
| 'elastic_user'
| 'kibana_user'
| 'kibana_system_user'
| 'other_user'
| 'kibana_service_account'
| 'unknown';
};

http: {
Expand Down
1 change: 1 addition & 0 deletions src/core/server/server.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@ export interface CoreConfigUsageData {
};
apiVersion: string;
healthCheckDelayMs: number;
principal: 'elastic_user' | 'kibana_user' | 'kibana_system_user' | 'other_user' | 'kibana_service_account' | 'unknown';
};
// (undocumented)
http: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,13 @@ export function getCoreUsageCollector(
'The interval in miliseconds between health check requests Kibana sends to the Elasticsearch.',
},
},
principal: {
type: 'keyword',
_meta: {
description:
'Indicates how Kibana authenticates itself to Elasticsearch. If elasticsearch.username is configured, this can be any of: "elastic_user", "kibana_user", "kibana_system_user", or "other_user". Otherwise, if elasticsearch.serviceAccountToken is configured, this will be "kibana_service_account". Otherwise, this value will be "unknown", because some other principal might be used to authenticate Kibana to Elasticsearch (such as an x509 certificate), or authentication may be skipped altogether.',
},
},
},

http: {
Expand Down
6 changes: 6 additions & 0 deletions src/plugins/telemetry/schema/oss_plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -6088,6 +6088,12 @@
"_meta": {
"description": "The interval in miliseconds between health check requests Kibana sends to the Elasticsearch."
}
},
"principal": {
"type": "keyword",
"_meta": {
"description": "Indicates how Kibana authenticates itself to Elasticsearch. If elasticsearch.username is configured, this can be any of: \"elastic_user\", \"kibana_user\", \"kibana_system_user\", or \"other_user\". Otherwise, if elasticsearch.serviceAccountToken is configured, this will be \"kibana_service_account\". Otherwise, this value will be \"unknown\", because some other principal might be used to authenticate Kibana to Elasticsearch (such as an x509 certificate), or authentication may be skipped altogether."
}
}
}
},
Expand Down
64 changes: 31 additions & 33 deletions x-pack/plugins/security/server/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1817,41 +1817,39 @@ describe('createConfig()', () => {
`);
});

it('falls back to the global settings if provider is not known', async () => {
expect(
createMockConfig({ session: { idleTimeout: 123 } }).session.getExpirationTimeouts({
type: 'some type',
name: 'some name',
})
).toMatchInlineSnapshot(`
Object {
"idleTimeout": "PT0.123S",
"lifespan": null,
}
`);
it('falls back to the global settings if provider is not known or is undefined', async () => {
[{ type: 'some type', name: 'some name' }, undefined].forEach((provider) => {
expect(
createMockConfig({ session: { idleTimeout: 123 } }).session.getExpirationTimeouts(
provider
)
).toMatchInlineSnapshot(`
Object {
"idleTimeout": "PT0.123S",
"lifespan": "null",
}
`);

expect(
createMockConfig({ session: { lifespan: 456 } }).session.getExpirationTimeouts({
type: 'some type',
name: 'some name',
})
).toMatchInlineSnapshot(`
Object {
"idleTimeout": null,
"lifespan": "PT0.456S",
}
`);
expect(
createMockConfig({ session: { lifespan: 456 } }).session.getExpirationTimeouts(provider)
).toMatchInlineSnapshot(`
Object {
"idleTimeout": "null",
"lifespan": "PT0.456S",
}
`);

expect(
createMockConfig({
session: { idleTimeout: 123, lifespan: 456 },
}).session.getExpirationTimeouts({ type: 'some type', name: 'some name' })
).toMatchInlineSnapshot(`
Object {
"idleTimeout": "PT0.123S",
"lifespan": "PT0.456S",
}
`);
expect(
createMockConfig({
session: { idleTimeout: 123, lifespan: 456 },
}).session.getExpirationTimeouts(provider)
).toMatchInlineSnapshot(`
Object {
"idleTimeout": "PT0.123S",
"lifespan": "PT0.456S",
}
`);
});
});

it('uses provider overrides if specified (only idle timeout)', async () => {
Expand Down
11 changes: 9 additions & 2 deletions x-pack/plugins/security/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,11 +393,18 @@ function getSessionConfig(session: RawConfigType['session'], providers: Provider
const defaultAnonymousSessionLifespan = schema.duration().validate('30d');
return {
cleanupInterval: session.cleanupInterval,
getExpirationTimeouts({ type, name }: AuthenticationProvider) {
getExpirationTimeouts(provider: AuthenticationProvider | undefined) {
// Both idle timeout and lifespan from the provider specific session config can have three
// possible types of values: `Duration`, `null` and `undefined`. The `undefined` type means that
// provider doesn't override session config and we should fall back to the global one instead.
const providerSessionConfig = providers[type as keyof ProvidersConfigType]?.[name]?.session;
// Note: using an `undefined` provider argument returns the global timeouts.
let providerSessionConfig:
| { idleTimeout?: Duration | null; lifespan?: Duration | null }
| undefined;
if (provider) {
const { type, name } = provider;
providerSessionConfig = providers[type as keyof ProvidersConfigType]?.[name]?.session;
}

// We treat anonymous sessions differently since users can create them without realizing it. This may lead to a
// non controllable amount of sessions stored in the session index. To reduce the impact we set a 30 days lifespan
Expand Down
Loading

0 comments on commit e187d7d

Please sign in to comment.