diff --git a/src/core/server/core_usage_data/core_usage_data_service.mock.ts b/src/core/server/core_usage_data/core_usage_data_service.mock.ts index a03f79096004b..941ac5afacb40 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.mock.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.mock.ts @@ -47,6 +47,7 @@ const createStartContractMock = () => { keystoreConfigured: false, truststoreConfigured: false, }, + principal: 'unknown', }, http: { basePathConfigured: false, diff --git a/src/core/server/core_usage_data/core_usage_data_service.test.ts b/src/core/server/core_usage_data/core_usage_data_service.test.ts index 6dc4adb80adc0..89d83cfdee2b8 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.test.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.test.ts @@ -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; + 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', () => { diff --git a/src/core/server/core_usage_data/core_usage_data_service.ts b/src/core/server/core_usage_data/core_usage_data_service.ts index 7cf38dddc563e..73f63d4d634df 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.ts @@ -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 { `); }); - 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": "PT1H", + "lifespan": "null", + } + `); - 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 () => { diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index d5234a0935ca2..35b8d84eb94a7 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -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 diff --git a/x-pack/plugins/security/server/usage_collector/security_usage_collector.test.ts b/x-pack/plugins/security/server/usage_collector/security_usage_collector.test.ts index 6a1f6662e796e..0515a1e1969bf 100644 --- a/x-pack/plugins/security/server/usage_collector/security_usage_collector.test.ts +++ b/x-pack/plugins/security/server/usage_collector/security_usage_collector.test.ts @@ -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, + }); + }); + }); }); diff --git a/x-pack/plugins/security/server/usage_collector/security_usage_collector.ts b/x-pack/plugins/security/server/usage_collector/security_usage_collector.ts index 813e23a13ff37..15177132e0fb1 100644 --- a/x-pack/plugins/security/server/usage_collector/security_usage_collector.ts +++ b/x-pack/plugins/security/server/usage_collector/security_usage_collector.ts @@ -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, }; }, }); diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 862bcdd51023f..708179552d908 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -5490,6 +5490,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": { @@ -5525,6 +5531,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)." + } } } },