Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[7.15] Security usage data (#110548) #110823

Merged
merged 4 commits into from
Sep 2, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
13 changes: 10 additions & 3 deletions x-pack/plugins/security/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,18 +393,25 @@ 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
// for the anonymous sessions in case neither global nor provider specific lifespan is configured explicitly.
// We can remove this code once https://github.com/elastic/kibana/issues/68885 is resolved.
const providerLifespan =
type === 'anonymous' &&
provider?.type === 'anonymous' &&
providerSessionConfig?.lifespan === undefined &&
session.lifespan === undefined
? defaultAnonymousSessionLifespan
Expand Down
Loading