[Response Ops][Reporting] Add health API to inform whether conditions are sufficient for scheduled reports (#216857)

Resolves https://github.com/elastic/kibana/issues/216319

## Summary

Adds an internal reporting health API to return whether conditions are
sufficient to support scheduled reports. For scheduled reporting, we
need for security and API keys to be enabled in Elasticsearch and for a
permanent encryption key to be set for the encrypted saved objects
plugin.

```
GET kbn:/internal/reporting/_health

Response 
{
  "has_permanent_encryption_key": true,
  "is_sufficiently_secure": true
}
```

The issue also mentions returning whether a preconfigured email service
is configured, but that will be done as part of the main scheduled
reporting task.

## To Verify

1. Run kibana and ES with no special flags, both flags should be `true`
2. Run ES with `-E xpack.security.enabled=false`.
`is_sufficiently_secure` should be set to `false`
3. Run ES With `-E xpack.security.authc.api_key.enabled=false`.
`is_sufficient_secure` should be set to `false`

Note that in dev mode, an encryption key is auto-set if not defined in
the Kibana yml so `has_permanent_encryption_key` will always return
`true` in dev mode.

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Ying Mao 2025-04-07 16:46:02 -04:00 committed by GitHub
parent 04a3d3308f
commit 3d54923123
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 116 additions and 6 deletions

View file

@ -24,6 +24,7 @@ export const INTERNAL_ROUTES = {
DELETE_PREFIX: prefixInternalPath + '/jobs/delete', // docId is added to the final path
DOWNLOAD_PREFIX: prefixInternalPath + '/jobs/download', // docId is added to the final path
},
HEALTH: prefixInternalPath + '/_health',
GENERATE_PREFIX: prefixInternalPath + '/generate', // exportTypeId is added to the final path
};

View file

@ -109,6 +109,11 @@ export interface ReportingServerInfo {
uuid: string;
}
export interface ReportingHealthInfo {
isSufficientlySecure: boolean;
hasPermanentEncryptionKey: boolean;
}
export type IlmPolicyMigrationStatus = 'policy-not-found' | 'indices-not-managed-by-policy' | 'ok';
export interface IlmPolicyStatusResponse {

View file

@ -18,6 +18,7 @@
"requiredPlugins": [
"data",
"discover",
"encryptedSavedObjects",
"fieldFormats",
"home",
"management",

View file

@ -28,7 +28,7 @@ import type { DiscoverServerPluginStart } from '@kbn/discover-plugin/server';
import type { FeaturesPluginSetup } from '@kbn/features-plugin/server';
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/server';
import type { LicensingPluginStart } from '@kbn/licensing-plugin/server';
import type { ReportingServerInfo } from '@kbn/reporting-common/types';
import type { ReportingHealthInfo, ReportingServerInfo } from '@kbn/reporting-common/types';
import { CsvSearchSourceExportType, CsvV2ExportType } from '@kbn/reporting-export-types-csv';
import { PdfExportType, PdfV1ExportType } from '@kbn/reporting-export-types-pdf';
import { PngExportType } from '@kbn/reporting-export-types-png';
@ -46,6 +46,7 @@ import type { UsageCounter } from '@kbn/usage-collection-plugin/server';
import { checkLicense } from '@kbn/reporting-server/check_license';
import { ExportTypesRegistry } from '@kbn/reporting-server/export_types_registry';
import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server';
import type { ReportingSetup } from '.';
import { createConfig } from './config';
import { reportingEventLoggerFactory } from './lib/event_logger/logger';
@ -56,15 +57,16 @@ import { EventTracker } from './usage';
export interface ReportingInternalSetup {
basePath: Pick<IBasePath, 'set'>;
router: ReportingPluginRouter;
docLinks: DocLinksServiceSetup;
encryptedSavedObjects: EncryptedSavedObjectsPluginSetup;
features: FeaturesPluginSetup;
logger: Logger;
router: ReportingPluginRouter;
security?: SecurityPluginSetup;
spaces?: SpacesPluginSetup;
usageCounter?: UsageCounter;
taskManager: TaskManagerSetupContract;
logger: Logger;
status: StatusServiceSetup;
docLinks: DocLinksServiceSetup;
taskManager: TaskManagerSetupContract;
usageCounter?: UsageCounter;
}
export interface ReportingInternalStart {
@ -247,6 +249,32 @@ export class ReportingCore {
};
}
public async getHealthInfo(): Promise<ReportingHealthInfo> {
const { encryptedSavedObjects } = this.getPluginSetupDeps();
const { securityService } = await this.getPluginStartDeps();
const isSecurityEnabled = await this.isSecurityEnabled();
let isSufficientlySecure: boolean;
const apiKeysAreEnabled = (await securityService.authc.apiKeys.areAPIKeysEnabled()) ?? false;
if (isSecurityEnabled === null) {
isSufficientlySecure = false;
} else {
// if esSecurityIsEnabled = true, then areApiKeysEnabled must be true to be considered secure
// if esSecurityIsEnabled = false, then it does not matter what areApiKeysEnabled is
if (!isSecurityEnabled) {
isSufficientlySecure = false;
} else {
isSufficientlySecure = apiKeysAreEnabled;
}
}
return {
isSufficientlySecure,
hasPermanentEncryptionKey: encryptedSavedObjects.canEncrypt,
};
}
/*
* Gives synchronous access to the config
*/
@ -307,6 +335,22 @@ export class ReportingCore {
);
}
public async isSecurityEnabled() {
const { license$ } = (await this.getPluginStartDeps()).licensing;
return await Rx.firstValueFrom(
license$.pipe(
map((license) => {
if (!license || !license?.isAvailable) {
return null;
}
const { isEnabled } = license.getFeature('security');
return isEnabled;
})
)
);
}
/*
* Gives synchronous access to the setupDeps
*/

View file

@ -9,6 +9,7 @@ import type { Logger } from '@kbn/core/server';
import { ReportingCore } from '..';
import { registerDeprecationsRoutes } from './internal/deprecations/deprecations';
import { registerDiagnosticRoutes } from './internal/diagnostic';
import { registerHealthRoute } from './internal/health';
import { registerGenerationRoutesInternal } from './internal/generate/generate_from_jobparams';
import { registerJobInfoRoutesInternal } from './internal/management/jobs';
import { registerGenerationRoutesPublic } from './public/generate_from_jobparams';
@ -16,6 +17,7 @@ import { registerJobInfoRoutesPublic } from './public/jobs';
export function registerRoutes(reporting: ReportingCore, logger: Logger) {
registerDeprecationsRoutes(reporting, logger);
registerHealthRoute(reporting, logger);
registerDiagnosticRoutes(reporting, logger);
registerGenerationRoutesInternal(reporting, logger);
registerJobInfoRoutesInternal(reporting);

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Logger } from '@kbn/core/server';
import { INTERNAL_ROUTES } from '@kbn/reporting-common';
import type { ReportingCore } from '../../..';
import { authorizedUserPreRouting } from '../../common';
const path = INTERNAL_ROUTES.HEALTH;
export const registerHealthRoute = (reporting: ReportingCore, logger: Logger) => {
const { router } = reporting.getPluginSetupDeps();
router.get(
{
path,
security: {
authz: {
enabled: false,
reason: 'This route is opted out from authorization',
},
},
validate: false,
options: { access: 'internal' },
},
authorizedUserPreRouting(reporting, async (_user, _context, req, res) => {
try {
const healthInfo = await reporting.getHealthInfo();
return res.ok({
body: {
has_permanent_encryption_key: healthInfo.hasPermanentEncryptionKey,
is_sufficiently_secure: healthInfo.isSufficientlySecure,
},
});
} catch (err) {
logger.error(err);
return res.custom({ statusCode: 500 });
}
})
);
};

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { registerHealthRoute } from './health';

View file

@ -19,6 +19,7 @@ import {
} from '@kbn/core/server/mocks';
import { dataPluginMock } from '@kbn/data-plugin/server/mocks';
import { discoverPluginMock } from '@kbn/discover-plugin/server/mocks';
import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks';
import { featuresPluginMock } from '@kbn/features-plugin/server/mocks';
import { FieldFormatsRegistry } from '@kbn/field-formats-plugin/common';
import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks';
@ -38,6 +39,7 @@ export const createMockPluginSetup = (
setupMock: Partial<Record<keyof ReportingInternalSetup, any>>
): ReportingInternalSetup => {
return {
encryptedSavedObjects: encryptedSavedObjectsMock.createSetup(),
features: featuresPluginMock.createSetup(),
basePath: { set: jest.fn() },
router: { get: jest.fn(), post: jest.fn(), put: jest.fn(), delete: jest.fn() },

View file

@ -31,6 +31,7 @@ import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import { ExportTypesRegistry } from '@kbn/reporting-server/export_types_registry';
import type { AuthenticatedUser } from '@kbn/core-security-common';
import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server';
/**
* Plugin Setup Contract
@ -48,6 +49,7 @@ export type ReportingUser = { username: AuthenticatedUser['username'] } | false;
export type ScrollConfig = ReportingConfigType['csv']['scroll'];
export interface ReportingSetupDeps {
encryptedSavedObjects: EncryptedSavedObjectsPluginSetup;
features: FeaturesPluginSetup;
screenshotMode: ScreenshotModePluginSetup;
taskManager: TaskManagerSetupContract;

View file

@ -14,6 +14,7 @@
"@kbn/screenshot-mode-plugin",
"@kbn/share-plugin",
"@kbn/ui-actions-plugin",
"@kbn/encrypted-saved-objects-plugin",
"@kbn/usage-collection-plugin",
"@kbn/field-formats-plugin",
"@kbn/features-plugin",