diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts index b15793527b7e5..9581db9410832 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts @@ -276,6 +276,7 @@ describe('features', () => { actions.version, ...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []), ...(expectGetFeatures ? [actions.api.get('features')] : []), + ...(expectGetFeatures ? [actions.api.get('taskManager')] : []), ...(expectManageSpaces ? [ actions.space.manage, @@ -492,6 +493,7 @@ describe('features', () => { actions.version, ...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []), ...(expectGetFeatures ? [actions.api.get('features')] : []), + ...(expectGetFeatures ? [actions.api.get('taskManager')] : []), ...(expectManageSpaces ? [ actions.space.manage, @@ -558,6 +560,7 @@ describe('features', () => { actions.version, ...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []), ...(expectGetFeatures ? [actions.api.get('features')] : []), + ...(expectGetFeatures ? [actions.api.get('taskManager')] : []), ...(expectManageSpaces ? [ actions.space.manage, @@ -625,6 +628,7 @@ describe('features', () => { actions.version, ...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []), ...(expectGetFeatures ? [actions.api.get('features')] : []), + ...(expectGetFeatures ? [actions.api.get('taskManager')] : []), ...(expectManageSpaces ? [ actions.space.manage, @@ -893,6 +897,7 @@ describe('subFeatures', () => { actions.version, actions.api.get('decryptedTelemetry'), actions.api.get('features'), + actions.api.get('taskManager'), actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), @@ -1059,6 +1064,7 @@ describe('subFeatures', () => { actions.version, actions.api.get('decryptedTelemetry'), actions.api.get('features'), + actions.api.get('taskManager'), actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), @@ -1292,6 +1298,7 @@ describe('subFeatures', () => { actions.version, actions.api.get('decryptedTelemetry'), actions.api.get('features'), + actions.api.get('taskManager'), actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), @@ -1431,6 +1438,7 @@ describe('subFeatures', () => { actions.version, actions.api.get('decryptedTelemetry'), actions.api.get('features'), + actions.api.get('taskManager'), actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), @@ -1613,6 +1621,7 @@ describe('subFeatures', () => { actions.version, actions.api.get('decryptedTelemetry'), actions.api.get('features'), + actions.api.get('taskManager'), actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), @@ -1749,6 +1758,7 @@ describe('subFeatures', () => { actions.version, actions.api.get('decryptedTelemetry'), actions.api.get('features'), + actions.api.get('taskManager'), actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), @@ -1980,6 +1990,7 @@ describe('subFeatures', () => { actions.version, actions.api.get('decryptedTelemetry'), actions.api.get('features'), + actions.api.get('taskManager'), actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), @@ -2245,6 +2256,7 @@ describe('subFeatures', () => { actions.version, actions.api.get('decryptedTelemetry'), actions.api.get('features'), + actions.api.get('taskManager'), actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.ts index 0b2ab93c966c0..16a53411d9c3d 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.ts @@ -106,6 +106,7 @@ export function privilegesFactory( actions.version, actions.api.get('decryptedTelemetry'), actions.api.get('features'), + actions.api.get('taskManager'), actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), diff --git a/x-pack/plugins/task_manager/server/plugin.ts b/x-pack/plugins/task_manager/server/plugin.ts index 4e1db9c274b5e..c0f0994a7ead5 100644 --- a/x-pack/plugins/task_manager/server/plugin.ts +++ b/x-pack/plugins/task_manager/server/plugin.ts @@ -66,12 +66,14 @@ export class TaskManagerPlugin private middleware: Middleware = createInitialMiddleware(); private elasticsearchAndSOAvailability$?: Observable; private monitoringStats$ = new Subject(); + private readonly kibanaVersion: PluginInitializerContext['env']['packageInfo']['version']; constructor(private readonly initContext: PluginInitializerContext) { this.initContext = initContext; this.logger = initContext.logger.get(); this.config = initContext.config.get(); this.definitions = new TaskTypeDictionary(this.logger); + this.kibanaVersion = initContext.env.packageInfo.version; } public setup( @@ -92,15 +94,26 @@ export class TaskManagerPlugin this.logger.info(`TaskManager is identified by the Kibana UUID: ${this.taskManagerId}`); } + const startServicesPromise = core.getStartServices().then(([coreServices]) => ({ + elasticsearch: coreServices.elasticsearch, + })); + + const usageCounter = plugins.usageCollection?.createUsageCounter(`taskManager`); + // Routes const router = core.http.createRouter(); - const { serviceStatus$, monitoredHealth$ } = healthRoute( + const { serviceStatus$, monitoredHealth$ } = healthRoute({ router, - this.monitoringStats$, - this.logger, - this.taskManagerId, - this.config! - ); + monitoringStats$: this.monitoringStats$, + logger: this.logger, + taskManagerId: this.taskManagerId, + config: this.config!, + usageCounter, + kibanaVersion: this.kibanaVersion, + kibanaIndexName: core.savedObjects.getKibanaIndex(), + getClusterClient: () => + startServicesPromise.then(({ elasticsearch }) => elasticsearch.client), + }); core.status.derivedStatus$.subscribe((status) => this.logger.debug(`status core.status.derivedStatus now set to ${status.level}`) diff --git a/x-pack/plugins/task_manager/server/routes/health.test.ts b/x-pack/plugins/task_manager/server/routes/health.test.ts index 663dee2120eab..b6fecdd02abb7 100644 --- a/x-pack/plugins/task_manager/server/routes/health.test.ts +++ b/x-pack/plugins/task_manager/server/routes/health.test.ts @@ -13,7 +13,8 @@ import { httpServiceMock } from 'src/core/server/mocks'; import { healthRoute } from './health'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { sleep } from '../test_utils'; -import { loggingSystemMock } from '../../../../../src/core/server/mocks'; +import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; +import { usageCountersServiceMock } from 'src/plugins/usage_collection/server/usage_counters/usage_counters_service.mock'; import { HealthStatus, MonitoringStats, @@ -29,6 +30,23 @@ jest.mock('../lib/log_health_metrics', () => ({ logHealthMetrics: jest.fn(), })); +const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); +const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const createMockClusterClient = (response: any) => { + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.hasPrivileges.mockResolvedValue({ + body: response, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + const mockClusterClient = elasticsearchServiceMock.createClusterClient(); + mockClusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + + return { mockClusterClient, mockScopedClusterClient }; +}; + describe('healthRoute', () => { const logger = loggingSystemMock.create().get(); @@ -38,13 +56,132 @@ describe('healthRoute', () => { it('registers the route', async () => { const router = httpServiceMock.createRouter(); - healthRoute(router, of(), logger, uuid.v4(), getTaskManagerConfig()); + healthRoute({ + router, + monitoringStats$: of(), + logger, + taskManagerId: uuid.v4(), + config: getTaskManagerConfig(), + kibanaVersion: '8.0', + kibanaIndexName: '.kibana', + getClusterClient: () => Promise.resolve(elasticsearchServiceMock.createClusterClient()), + usageCounter: mockUsageCounter, + }); const [config] = router.get.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/task_manager/_health"`); }); + it('checks user privileges and increments usage counter when API is accessed', async () => { + const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient({ + has_all_requested: false, + }); + const router = httpServiceMock.createRouter(); + healthRoute({ + router, + monitoringStats$: of(), + logger, + taskManagerId: uuid.v4(), + config: getTaskManagerConfig(), + kibanaVersion: '8.0', + kibanaIndexName: 'foo', + getClusterClient: () => Promise.resolve(mockClusterClient), + usageCounter: mockUsageCounter, + }); + + const [, handler] = router.get.mock.calls[0]; + const [context, req, res] = mockHandlerArguments({}, {}, ['ok']); + await handler(context, req, res); + + expect(mockScopedClusterClient.asCurrentUser.security.hasPrivileges).toHaveBeenCalledWith({ + body: { + application: [ + { + application: `kibana-foo`, + resources: ['*'], + privileges: [`api:8.0:taskManager`], + }, + ], + }, + }); + expect(mockUsageCounter.incrementCounter).toHaveBeenCalledTimes(1); + expect(mockUsageCounter.incrementCounter).toHaveBeenNthCalledWith(1, { + counterName: `taskManagerHealthApiAccess`, + counterType: 'taskManagerHealthApi', + incrementBy: 1, + }); + }); + + it('checks user privileges and increments admin usage counter when API is accessed when user has access to task manager feature', async () => { + const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient({ + has_all_requested: true, + }); + const router = httpServiceMock.createRouter(); + healthRoute({ + router, + monitoringStats$: of(), + logger, + taskManagerId: uuid.v4(), + config: getTaskManagerConfig(), + kibanaVersion: '8.0', + kibanaIndexName: 'foo', + getClusterClient: () => Promise.resolve(mockClusterClient), + usageCounter: mockUsageCounter, + }); + + const [, handler] = router.get.mock.calls[0]; + const [context, req, res] = mockHandlerArguments({}, {}, ['ok']); + await handler(context, req, res); + + expect(mockScopedClusterClient.asCurrentUser.security.hasPrivileges).toHaveBeenCalledWith({ + body: { + application: [ + { + application: `kibana-foo`, + resources: ['*'], + privileges: [`api:8.0:taskManager`], + }, + ], + }, + }); + + expect(mockUsageCounter.incrementCounter).toHaveBeenCalledTimes(2); + expect(mockUsageCounter.incrementCounter).toHaveBeenNthCalledWith(1, { + counterName: `taskManagerHealthApiAccess`, + counterType: 'taskManagerHealthApi', + incrementBy: 1, + }); + expect(mockUsageCounter.incrementCounter).toHaveBeenNthCalledWith(2, { + counterName: `taskManagerHealthApiAdminAccess`, + counterType: 'taskManagerHealthApi', + incrementBy: 1, + }); + }); + + it('skips checking user privileges if usage counter is undefined', async () => { + const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient({ + has_all_requested: false, + }); + const router = httpServiceMock.createRouter(); + healthRoute({ + router, + monitoringStats$: of(), + logger, + taskManagerId: uuid.v4(), + config: getTaskManagerConfig(), + kibanaVersion: '8.0', + kibanaIndexName: 'foo', + getClusterClient: () => Promise.resolve(mockClusterClient), + }); + + const [, handler] = router.get.mock.calls[0]; + const [context, req, res] = mockHandlerArguments({}, {}, ['ok']); + await handler(context, req, res); + + expect(mockScopedClusterClient.asCurrentUser.security.hasPrivileges).not.toHaveBeenCalled(); + }); + it('logs the Task Manager stats at a fixed interval', async () => { const router = httpServiceMock.createRouter(); const calculateHealthStatus = calculateHealthStatusMock.create(); @@ -60,20 +197,24 @@ describe('healthRoute', () => { const stats$ = new Subject(); const id = uuid.v4(); - healthRoute( + healthRoute({ router, - stats$, + monitoringStats$: stats$, logger, - id, - getTaskManagerConfig({ + taskManagerId: id, + config: getTaskManagerConfig({ monitored_stats_required_freshness: 1000, monitored_stats_health_verbose_log: { enabled: true, warn_delayed_task_start_in_seconds: 100, }, monitored_aggregated_stats_refresh_rate: 60000, - }) - ); + }), + kibanaVersion: '8.0', + kibanaIndexName: '.kibana', + getClusterClient: () => Promise.resolve(elasticsearchServiceMock.createClusterClient()), + usageCounter: mockUsageCounter, + }); stats$.next(mockStat); await sleep(500); @@ -114,20 +255,24 @@ describe('healthRoute', () => { const stats$ = new Subject(); const id = uuid.v4(); - healthRoute( + healthRoute({ router, - stats$, + monitoringStats$: stats$, logger, - id, - getTaskManagerConfig({ + taskManagerId: id, + config: getTaskManagerConfig({ monitored_stats_required_freshness: 1000, monitored_stats_health_verbose_log: { enabled: true, warn_delayed_task_start_in_seconds: 120, }, monitored_aggregated_stats_refresh_rate: 60000, - }) - ); + }), + kibanaVersion: '8.0', + kibanaIndexName: '.kibana', + getClusterClient: () => Promise.resolve(elasticsearchServiceMock.createClusterClient()), + usageCounter: mockUsageCounter, + }); stats$.next(warnRuntimeStat); await sleep(1001); @@ -186,20 +331,24 @@ describe('healthRoute', () => { const stats$ = new Subject(); const id = uuid.v4(); - healthRoute( + healthRoute({ router, - stats$, + monitoringStats$: stats$, logger, - id, - getTaskManagerConfig({ + taskManagerId: id, + config: getTaskManagerConfig({ monitored_stats_required_freshness: 1000, monitored_stats_health_verbose_log: { enabled: true, warn_delayed_task_start_in_seconds: 120, }, monitored_aggregated_stats_refresh_rate: 60000, - }) - ); + }), + kibanaVersion: '8.0', + kibanaIndexName: '.kibana', + getClusterClient: () => Promise.resolve(elasticsearchServiceMock.createClusterClient()), + usageCounter: mockUsageCounter, + }); stats$.next(errorRuntimeStat); await sleep(1001); @@ -249,16 +398,20 @@ describe('healthRoute', () => { const stats$ = new Subject(); - const { serviceStatus$ } = healthRoute( + const { serviceStatus$ } = healthRoute({ router, - stats$, + monitoringStats$: stats$, logger, - uuid.v4(), - getTaskManagerConfig({ + taskManagerId: uuid.v4(), + config: getTaskManagerConfig({ monitored_stats_required_freshness: 1000, monitored_aggregated_stats_refresh_rate: 60000, - }) - ); + }), + kibanaVersion: '8.0', + kibanaIndexName: '.kibana', + getClusterClient: () => Promise.resolve(elasticsearchServiceMock.createClusterClient()), + usageCounter: mockUsageCounter, + }); const serviceStatus = getLatest(serviceStatus$); @@ -326,16 +479,20 @@ describe('healthRoute', () => { const stats$ = new Subject(); - healthRoute( + healthRoute({ router, - stats$, + monitoringStats$: stats$, logger, - uuid.v4(), - getTaskManagerConfig({ + taskManagerId: uuid.v4(), + config: getTaskManagerConfig({ monitored_stats_required_freshness: 5000, monitored_aggregated_stats_refresh_rate: 60000, - }) - ); + }), + kibanaVersion: '8.0', + kibanaIndexName: '.kibana', + getClusterClient: () => Promise.resolve(elasticsearchServiceMock.createClusterClient()), + usageCounter: mockUsageCounter, + }); await sleep(0); @@ -395,16 +552,20 @@ describe('healthRoute', () => { const router = httpServiceMock.createRouter(); const stats$ = new Subject(); - healthRoute( + healthRoute({ router, - stats$, + monitoringStats$: stats$, logger, - uuid.v4(), - getTaskManagerConfig({ + taskManagerId: uuid.v4(), + config: getTaskManagerConfig({ monitored_stats_required_freshness: 1000, monitored_aggregated_stats_refresh_rate: 60000, - }) - ); + }), + kibanaVersion: '8.0', + kibanaIndexName: '.kibana', + getClusterClient: () => Promise.resolve(elasticsearchServiceMock.createClusterClient()), + usageCounter: mockUsageCounter, + }); await sleep(0); diff --git a/x-pack/plugins/task_manager/server/routes/health.ts b/x-pack/plugins/task_manager/server/routes/health.ts index 4101662184430..f980cf82e76ca 100644 --- a/x-pack/plugins/task_manager/server/routes/health.ts +++ b/x-pack/plugins/task_manager/server/routes/health.ts @@ -12,9 +12,11 @@ import { IKibanaResponse, KibanaResponseFactory, } from 'kibana/server'; +import { IClusterClient } from 'src/core/server'; import { Observable, Subject } from 'rxjs'; import { tap, map } from 'rxjs/operators'; import { throttleTime } from 'rxjs/operators'; +import { UsageCounter } from 'src/plugins/usage_collection/server'; import { Logger, ServiceStatus, ServiceStatusLevels } from '../../../../../src/core/server'; import { MonitoringStats, @@ -47,16 +49,34 @@ const LEVEL_SUMMARY = { */ type TaskManagerServiceStatus = ServiceStatus; -export function healthRoute( - router: IRouter, - monitoringStats$: Observable, - logger: Logger, - taskManagerId: string, - config: TaskManagerConfig -): { +export interface HealthRouteParams { + router: IRouter; + monitoringStats$: Observable; + logger: Logger; + taskManagerId: string; + config: TaskManagerConfig; + kibanaVersion: string; + kibanaIndexName: string; + getClusterClient: () => Promise; + usageCounter?: UsageCounter; +} + +export function healthRoute(params: HealthRouteParams): { serviceStatus$: Observable; monitoredHealth$: Observable; } { + const { + router, + monitoringStats$, + logger, + taskManagerId, + config, + kibanaVersion, + kibanaIndexName, + getClusterClient, + usageCounter, + } = params; + // if "hot" health stats are any more stale than monitored_stats_required_freshness (pollInterval +1s buffer by default) // consider the system unhealthy const requiredHotStatsFreshness: number = config.monitored_stats_required_freshness; @@ -95,6 +115,8 @@ export function healthRoute( router.get( { path: '/api/task_manager/_health', + // Uncomment when we determine that we can restrict API usage to Global admins based on telemetry + // options: { tags: ['access:taskManager'] }, validate: false, }, async function ( @@ -102,6 +124,39 @@ export function healthRoute( req: KibanaRequest, res: KibanaResponseFactory ): Promise { + // If we are able to count usage, we want to check whether the user has access to + // the `taskManager` feature, which is only available as part of the Global All privilege. + if (usageCounter) { + const clusterClient = await getClusterClient(); + const { body: hasPrivilegesResponse } = await clusterClient + .asScoped(req) + .asCurrentUser.security.hasPrivileges({ + body: { + application: [ + { + application: `kibana-${kibanaIndexName}`, + resources: ['*'], + privileges: [`api:${kibanaVersion}:taskManager`], + }, + ], + }, + }); + + // Keep track of total access vs admin access + usageCounter.incrementCounter({ + counterName: `taskManagerHealthApiAccess`, + counterType: 'taskManagerHealthApi', + incrementBy: 1, + }); + if (hasPrivilegesResponse.has_all_requested) { + usageCounter.incrementCounter({ + counterName: `taskManagerHealthApiAdminAccess`, + counterType: 'taskManagerHealthApi', + incrementBy: 1, + }); + } + } + return res.ok({ body: lastMonitoredStats ? getHealthStatus(lastMonitoredStats)