Security usage data (#110548)

This commit is contained in:
Joe Portner 2021-09-01 10:35:36 -04:00 committed by GitHub
parent 77890b1ccf
commit b17d87e508
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 332 additions and 115 deletions

View file

@ -47,6 +47,7 @@ const createStartContractMock = () => {
keystoreConfigured: false,
truststoreConfigured: false,
},
principal: 'unknown',
},
http: {
basePathConfigured: false,

View file

@ -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';
@ -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' },
@ -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);
});
@ -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(
@ -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 {
@ -226,6 +235,7 @@ describe('CoreUsageDataService', () => {
"logQueries": false,
"numberOfHostsConfigured": 1,
"pingTimeoutMs": 30000,
"principal": "unknown",
"requestHeadersWhitelistConfigured": false,
"requestTimeoutMs": 30000,
"shardTimeoutMs": 30000,
@ -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', () => {

View file

@ -29,6 +29,7 @@ import type {
CoreUsageDataStart,
CoreUsageDataSetup,
ConfigUsageData,
CoreConfigUsageData,
} from './types';
import { isConfigured } from './is_configured';
import { ElasticsearchServiceStart } from '../elasticsearch';
@ -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),
@ -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;
}

View file

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

View file

@ -293,6 +293,7 @@ export interface CoreConfigUsageData {
};
apiVersion: string;
healthCheckDelayMs: number;
principal: 'elastic_user' | 'kibana_user' | 'kibana_system_user' | 'other_user' | 'kibana_service_account' | 'unknown';
};
// (undocumented)
http: {

View file

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

View file

@ -5957,6 +5957,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."
}
}
}
},

View file

@ -1689,41 +1689,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": "P30D",
}
`);
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": "P30D",
}
`);
expect(
createMockConfig({ session: { lifespan: 456 } }).session.getExpirationTimeouts({
type: 'some type',
name: 'some name',
})
).toMatchInlineSnapshot(`
Object {
"idleTimeout": "PT1H",
"lifespan": "PT0.456S",
}
`);
expect(
createMockConfig({ session: { lifespan: 456 } }).session.getExpirationTimeouts(provider)
).toMatchInlineSnapshot(`
Object {
"idleTimeout": "PT1H",
"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 () => {

View file

@ -391,11 +391,18 @@ export function createConfig(
function getSessionConfig(session: RawConfigType['session'], providers: ProvidersConfigType) {
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;
}
const [idleTimeout, lifespan] = [
[session.idleTimeout, providerSessionConfig?.idleTimeout],
[session.lifespan, providerSessionConfig?.lifespan],

View file

@ -40,6 +40,17 @@ describe('Security UsageCollector', () => {
};
const collectorFetchContext = createCollectorFetchContextMock();
const DEFAULT_USAGE = {
auditLoggingEnabled: false,
accessAgreementEnabled: false,
authProviderCount: 1,
enabledAuthProviders: ['basic'],
loginSelectorEnabled: false,
httpAuthSchemes: ['apikey'],
sessionIdleTimeoutInMinutes: 60,
sessionLifespanInMinutes: 43200,
sessionCleanupInMinutes: 60,
};
describe('initialization', () => {
it('handles an undefined usage collector', () => {
@ -75,14 +86,7 @@ describe('Security UsageCollector', () => {
.getCollectorByType('security')
?.fetch(collectorFetchContext);
expect(usage).toEqual({
auditLoggingEnabled: false,
accessAgreementEnabled: false,
authProviderCount: 1,
enabledAuthProviders: ['basic'],
loginSelectorEnabled: false,
httpAuthSchemes: ['apikey'],
});
expect(usage).toEqual(DEFAULT_USAGE);
});
it('reports correctly when security is disabled in Elasticsearch', async () => {
@ -103,6 +107,9 @@ describe('Security UsageCollector', () => {
enabledAuthProviders: [],
loginSelectorEnabled: false,
httpAuthSchemes: [],
sessionIdleTimeoutInMinutes: 0,
sessionLifespanInMinutes: 0,
sessionCleanupInMinutes: 0,
});
});
@ -140,14 +147,7 @@ describe('Security UsageCollector', () => {
.getCollectorByType('security')
?.fetch(collectorFetchContext);
expect(usage).toEqual({
auditLoggingEnabled: false,
accessAgreementEnabled: false,
authProviderCount: 1,
enabledAuthProviders: ['basic'],
loginSelectorEnabled: false,
httpAuthSchemes: ['apikey'],
});
expect(usage).toEqual(DEFAULT_USAGE);
});
it('reports the types and count of enabled auth providers', async () => {
@ -190,12 +190,10 @@ describe('Security UsageCollector', () => {
?.fetch(collectorFetchContext);
expect(usage).toEqual({
auditLoggingEnabled: false,
accessAgreementEnabled: false,
...DEFAULT_USAGE,
authProviderCount: 3,
enabledAuthProviders: ['saml', 'pki'],
loginSelectorEnabled: true,
httpAuthSchemes: ['apikey'],
});
});
});
@ -228,12 +226,9 @@ describe('Security UsageCollector', () => {
?.fetch(collectorFetchContext);
expect(usage).toEqual({
auditLoggingEnabled: false,
...DEFAULT_USAGE,
accessAgreementEnabled: true,
authProviderCount: 1,
enabledAuthProviders: ['saml'],
loginSelectorEnabled: false,
httpAuthSchemes: ['apikey'],
});
});
it('does not report the access agreement if the license does not permit it', async () => {
@ -266,12 +261,9 @@ describe('Security UsageCollector', () => {
?.fetch(collectorFetchContext);
expect(usage).toEqual({
auditLoggingEnabled: false,
...DEFAULT_USAGE,
accessAgreementEnabled: false,
authProviderCount: 1,
enabledAuthProviders: ['saml'],
loginSelectorEnabled: false,
httpAuthSchemes: ['apikey'],
});
});
@ -307,12 +299,9 @@ describe('Security UsageCollector', () => {
?.fetch(collectorFetchContext);
expect(usage).toEqual({
auditLoggingEnabled: false,
...DEFAULT_USAGE,
accessAgreementEnabled: false,
authProviderCount: 1,
enabledAuthProviders: ['saml'],
loginSelectorEnabled: false,
httpAuthSchemes: ['apikey'],
});
});
});
@ -346,27 +335,29 @@ describe('Security UsageCollector', () => {
?.fetch(collectorFetchContext);
expect(usage).toEqual({
auditLoggingEnabled: false,
accessAgreementEnabled: false,
authProviderCount: 1,
...DEFAULT_USAGE,
enabledAuthProviders: ['saml'],
loginSelectorEnabled: true,
httpAuthSchemes: ['apikey'],
});
});
});
describe('audit logging', () => {
it('reports when audit logging is enabled', async () => {
it('reports when legacy audit logging is enabled (and ECS audit logging is not enabled)', async () => {
const config = createSecurityConfig(
ConfigSchema.validate({
audit: {
enabled: true,
appender: undefined,
},
})
);
const usageCollection = usageCollectionPluginMock.createSetupContract();
const license = createSecurityLicense({ isLicenseAvailable: true, allowAuditLogging: true });
const license = createSecurityLicense({
isLicenseAvailable: true,
allowLegacyAuditLogging: true,
allowAuditLogging: true,
});
registerSecurityUsageCollector({ usageCollection, config, license });
const usage = await usageCollection
@ -374,12 +365,37 @@ describe('Security UsageCollector', () => {
?.fetch(collectorFetchContext);
expect(usage).toEqual({
...DEFAULT_USAGE,
auditLoggingEnabled: true,
accessAgreementEnabled: false,
authProviderCount: 1,
enabledAuthProviders: ['basic'],
loginSelectorEnabled: false,
httpAuthSchemes: ['apikey'],
auditLoggingType: 'legacy',
});
});
it('reports when ECS audit logging is enabled (and legacy audit logging is not enabled)', async () => {
const config = createSecurityConfig(
ConfigSchema.validate({
audit: {
enabled: true,
appender: { type: 'console', layout: { type: 'json' } },
},
})
);
const usageCollection = usageCollectionPluginMock.createSetupContract();
const license = createSecurityLicense({
isLicenseAvailable: true,
allowLegacyAuditLogging: true,
allowAuditLogging: true,
});
registerSecurityUsageCollector({ usageCollection, config, license });
const usage = await usageCollection
.getCollectorByType('security')
?.fetch(collectorFetchContext);
expect(usage).toEqual({
...DEFAULT_USAGE,
auditLoggingEnabled: true,
auditLoggingType: 'ecs',
});
});
@ -400,12 +416,9 @@ describe('Security UsageCollector', () => {
?.fetch(collectorFetchContext);
expect(usage).toEqual({
...DEFAULT_USAGE,
auditLoggingEnabled: false,
accessAgreementEnabled: false,
authProviderCount: 1,
enabledAuthProviders: ['basic'],
loginSelectorEnabled: false,
httpAuthSchemes: ['apikey'],
auditLoggingType: undefined,
});
});
});
@ -430,11 +443,7 @@ describe('Security UsageCollector', () => {
?.fetch(collectorFetchContext);
expect(usage).toEqual({
auditLoggingEnabled: false,
accessAgreementEnabled: false,
authProviderCount: 1,
enabledAuthProviders: ['basic'],
loginSelectorEnabled: false,
...DEFAULT_USAGE,
httpAuthSchemes: ['basic', 'Negotiate'],
});
});
@ -458,13 +467,34 @@ describe('Security UsageCollector', () => {
?.fetch(collectorFetchContext);
expect(usage).toEqual({
auditLoggingEnabled: false,
accessAgreementEnabled: false,
authProviderCount: 1,
enabledAuthProviders: ['basic'],
loginSelectorEnabled: false,
...DEFAULT_USAGE,
httpAuthSchemes: ['basic', 'Negotiate'],
});
});
});
describe('session', () => {
// Note: can't easily test deprecated 'sessionTimeout' value here because of the way that config deprecation renaming works
it('reports customized session idleTimeout, lifespan, and cleanupInterval', async () => {
const config = createSecurityConfig(
ConfigSchema.validate({
session: { idleTimeout: '123m', lifespan: '456m', cleanupInterval: '789m' },
})
);
const usageCollection = usageCollectionPluginMock.createSetupContract();
const license = createSecurityLicense({ isLicenseAvailable: true, allowAuditLogging: false });
registerSecurityUsageCollector({ usageCollection, config, license });
const usage = await usageCollection
.getCollectorByType('security')
?.fetch(collectorFetchContext);
expect(usage).toEqual({
...DEFAULT_USAGE,
sessionIdleTimeoutInMinutes: 123,
sessionLifespanInMinutes: 456,
sessionCleanupInMinutes: 789,
});
});
});
});

View file

@ -12,11 +12,15 @@ import type { ConfigType } from '../config';
interface Usage {
auditLoggingEnabled: boolean;
auditLoggingType?: 'ecs' | 'legacy';
loginSelectorEnabled: boolean;
accessAgreementEnabled: boolean;
authProviderCount: number;
enabledAuthProviders: string[];
httpAuthSchemes: string[];
sessionIdleTimeoutInMinutes: number;
sessionLifespanInMinutes: number;
sessionCleanupInMinutes: number;
}
interface Deps {
@ -58,6 +62,13 @@ export function registerSecurityUsageCollector({ usageCollection, config, licens
'Indicates if audit logging is both enabled and supported by the current license.',
},
},
auditLoggingType: {
type: 'keyword',
_meta: {
description:
'If auditLoggingEnabled is true, indicates what type is enabled (ECS or legacy).',
},
},
loginSelectorEnabled: {
type: 'boolean',
_meta: {
@ -98,6 +109,27 @@ export function registerSecurityUsageCollector({ usageCollection, config, licens
},
},
},
sessionIdleTimeoutInMinutes: {
type: 'long',
_meta: {
description:
'The global session idle timeout expiration that is configured, in minutes (0 if disabled).',
},
},
sessionLifespanInMinutes: {
type: 'long',
_meta: {
description:
'The global session lifespan expiration that is configured, in minutes (0 if disabled).',
},
},
sessionCleanupInMinutes: {
type: 'long',
_meta: {
description:
'The session cleanup interval that is configured, in minutes (0 if disabled).',
},
},
},
fetch: () => {
const {
@ -114,13 +146,23 @@ export function registerSecurityUsageCollector({ usageCollection, config, licens
authProviderCount: 0,
enabledAuthProviders: [],
httpAuthSchemes: [],
sessionIdleTimeoutInMinutes: 0,
sessionLifespanInMinutes: 0,
sessionCleanupInMinutes: 0,
};
}
const legacyAuditLoggingEnabled = allowLegacyAuditLogging && config.audit.enabled;
const auditLoggingEnabled =
const ecsAuditLoggingEnabled =
allowAuditLogging && config.audit.enabled && config.audit.appender != null;
let auditLoggingType: Usage['auditLoggingType'];
if (ecsAuditLoggingEnabled) {
auditLoggingType = 'ecs';
} else if (legacyAuditLoggingEnabled) {
auditLoggingType = 'legacy';
}
const loginSelectorEnabled = config.authc.selector.enabled;
const authProviderCount = config.authc.sortedProviders.length;
const enabledAuthProviders = [
@ -139,13 +181,22 @@ export function registerSecurityUsageCollector({ usageCollection, config, licens
WELL_KNOWN_AUTH_SCHEMES.includes(scheme.toLowerCase())
);
const sessionExpirations = config.session.getExpirationTimeouts(undefined); // use `undefined` to get global expiration values
const sessionIdleTimeoutInMinutes = sessionExpirations.idleTimeout?.asMinutes() ?? 0;
const sessionLifespanInMinutes = sessionExpirations.lifespan?.asMinutes() ?? 0;
const sessionCleanupInMinutes = config.session.cleanupInterval?.asMinutes() ?? 0;
return {
auditLoggingEnabled: legacyAuditLoggingEnabled || auditLoggingEnabled,
auditLoggingEnabled: legacyAuditLoggingEnabled || ecsAuditLoggingEnabled,
auditLoggingType,
loginSelectorEnabled,
accessAgreementEnabled,
authProviderCount,
enabledAuthProviders,
httpAuthSchemes,
sessionIdleTimeoutInMinutes,
sessionLifespanInMinutes,
sessionCleanupInMinutes,
};
},
});

View file

@ -5455,6 +5455,12 @@
"description": "Indicates if audit logging is both enabled and supported by the current license."
}
},
"auditLoggingType": {
"type": "keyword",
"_meta": {
"description": "If auditLoggingEnabled is true, indicates what type is enabled (ECS or legacy)."
}
},
"loginSelectorEnabled": {
"type": "boolean",
"_meta": {
@ -5490,6 +5496,24 @@
"description": "The set of enabled http auth schemes. Used for api-based usage, and when credentials are provided via reverse-proxy."
}
}
},
"sessionIdleTimeoutInMinutes": {
"type": "long",
"_meta": {
"description": "The global session idle timeout expiration that is configured, in minutes (0 if disabled)."
}
},
"sessionLifespanInMinutes": {
"type": "long",
"_meta": {
"description": "The global session lifespan expiration that is configured, in minutes (0 if disabled)."
}
},
"sessionCleanupInMinutes": {
"type": "long",
"_meta": {
"description": "The session cleanup interval that is configured, in minutes (0 if disabled)."
}
}
}
},