diff --git a/x-pack/plugins/fleet/common/services/routes.ts b/x-pack/plugins/fleet/common/services/routes.ts index 59c158fe4dee..17b193fd8624 100644 --- a/x-pack/plugins/fleet/common/services/routes.ts +++ b/x-pack/plugins/fleet/common/services/routes.ts @@ -257,7 +257,7 @@ export const settingsRoutesService = { }; export const appRoutesService = { - getCheckPermissionsPath: (fleetServerSetup?: boolean) => APP_API_ROUTES.CHECK_PERMISSIONS_PATTERN, + getCheckPermissionsPath: () => APP_API_ROUTES.CHECK_PERMISSIONS_PATTERN, getRegenerateServiceTokenPath: () => APP_API_ROUTES.GENERATE_SERVICE_TOKEN_PATTERN, postHealthCheckPath: () => APP_API_ROUTES.HEALTH_CHECK_PATTERN, }; diff --git a/x-pack/plugins/fleet/server/mocks/index.ts b/x-pack/plugins/fleet/server/mocks/index.ts index eb98d0e68eca..01d92b8c8f28 100644 --- a/x-pack/plugins/fleet/server/mocks/index.ts +++ b/x-pack/plugins/fleet/server/mocks/index.ts @@ -90,10 +90,9 @@ export const createFleetRequestHandlerContextMock = (): jest.Mocked< asCurrentUser: createPackagePolicyServiceMock(), asInternalUser: createPackagePolicyServiceMock(), }, - epm: { - internalSoClient: savedObjectsClientMock.create(), - }, + internalSoClient: savedObjectsClientMock.create(), spaceId: 'default', + limitedToPackages: undefined, }; }; diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index cadb7859cc2e..7bcf9502d59b 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -57,6 +57,13 @@ import type { ExperimentalFeatures } from '../common/experimental_features'; import { INTEGRATIONS_PLUGIN_ID } from '../common'; import { parseExperimentalConfigValue } from '../common/experimental_features'; +import { + getRouteRequiredAuthz, + makeRouterWithFleetAuthz, + calculateRouteAuthz, + getAuthzFromRequest, +} from './services/security'; + import { PLUGIN_ID, OUTPUT_SAVED_OBJECT_TYPE, @@ -93,7 +100,6 @@ import { fetchAgentsUsage, fetchFleetUsage, } from './collectors/register'; -import { getAuthzFromRequest, makeRouterWithFleetAuthz } from './routes/security'; import { FleetArtifactsClient } from './services/artifacts'; import type { FleetRouter } from './types/request_context'; import { TelemetryEventsSender } from './telemetry/sender'; @@ -337,7 +343,19 @@ export class FleetPlugin PLUGIN_ID, async (context, request) => { const plugin = this; - const esClient = (await context.core).elasticsearch.client; + const coreContext = await context.core; + const authz = await getAuthzFromRequest(request); + const esClient = coreContext.elasticsearch.client; + + const routeRequiredAuthz = getRouteRequiredAuthz(request.route.method, request.route.path); + const routeAuthz = routeRequiredAuthz + ? calculateRouteAuthz(authz, routeRequiredAuthz) + : undefined; + + const getInternalSoClient = (): SavedObjectsClientContract => + appContextService + .getSavedObjects() + .getScopedClient(request, { excludedExtensions: [SECURITY_EXTENSION_ID] }); return { get agentClient() { @@ -356,18 +374,21 @@ export class FleetPlugin asInternalUser: service.asInternalUser, }; }, - authz: await getAuthzFromRequest(request), - epm: { + authz, + + get internalSoClient() { // Use a lazy getter to avoid constructing this client when not used by a request handler - get internalSoClient() { - return appContextService - .getSavedObjects() - .getScopedClient(request, { excludedExtensions: [SECURITY_EXTENSION_ID] }); - }, + return getInternalSoClient(); }, get spaceId() { return deps.spaces.spacesService.getSpaceId(request); }, + + get limitedToPackages() { + if (routeAuthz && routeAuthz.granted) { + return routeAuthz.scopeDataToPackages; + } + }, }; } ); @@ -384,10 +405,11 @@ export class FleetPlugin // Only some endpoints require superuser so we pass a raw IRouter here // For all the routes we enforce the user to have role superuser - const { router: fleetAuthzRouter, onPostAuthHandler: fleetAuthzOnPostAuthHandler } = - makeRouterWithFleetAuthz(router); + const fleetAuthzRouter = makeRouterWithFleetAuthz( + router, + this.initializerContext.logger.get('fleet_authz_router') + ); - core.http.registerOnPostAuth(fleetAuthzOnPostAuthHandler); registerRoutes(fleetAuthzRouter, config); this.telemetryEventsSender.setup(deps.telemetry); diff --git a/x-pack/plugins/fleet/server/routes/agent/index.ts b/x-pack/plugins/fleet/server/routes/agent/index.ts index e8ac9a2de674..fd75c7a7020a 100644 --- a/x-pack/plugins/fleet/server/routes/agent/index.ts +++ b/x-pack/plugins/fleet/server/routes/agent/index.ts @@ -5,6 +5,8 @@ * 2.0. */ +import type { FleetAuthzRouter } from '../../services/security'; + import { AGENT_API_ROUTES } from '../../constants'; import { GetAgentsRequestSchema, @@ -30,7 +32,6 @@ import { } from '../../types'; import * as AgentService from '../../services/agents'; import type { FleetConfigType } from '../..'; -import type { FleetAuthzRouter } from '../security'; import { PostBulkUpdateAgentTagsRequestSchema } from '../../types/rest_spec/agent'; diff --git a/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts b/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts index 42f4560f736b..9d0fe813a03a 100644 --- a/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts @@ -67,7 +67,7 @@ export const getAgentPoliciesHandler: FleetRequestHandler< > = async (context, request, response) => { const coreContext = await context.core; const fleetContext = await context.fleet; - const soClient = fleetContext.epm.internalSoClient; + const soClient = fleetContext.internalSoClient; const esClient = coreContext.elasticsearch.client.asInternalUser; const { full: withPackagePolicies = false, ...restOfQuery } = request.query; try { @@ -98,7 +98,7 @@ export const bulkGetAgentPoliciesHandler: FleetRequestHandler< > = async (context, request, response) => { const coreContext = await context.core; const fleetContext = await context.fleet; - const soClient = fleetContext.epm.internalSoClient; + const soClient = fleetContext.internalSoClient; const esClient = coreContext.elasticsearch.client.asInternalUser; const { full: withPackagePolicies = false, ignoreMissing = false, ids } = request.body; try { @@ -158,7 +158,7 @@ export const createAgentPolicyHandler: FleetRequestHandler< > = async (context, request, response) => { const coreContext = await context.core; const fleetContext = await context.fleet; - const soClient = fleetContext.epm.internalSoClient; + const soClient = fleetContext.internalSoClient; const esClient = coreContext.elasticsearch.client.asInternalUser; const user = (await appContextService.getSecurity()?.authc.getCurrentUser(request)) || undefined; const withSysMonitoring = request.query.sys_monitoring ?? false; @@ -276,7 +276,7 @@ export const getFullAgentPolicy: FleetRequestHandler< TypeOf > = async (context, request, response) => { const fleetContext = await context.fleet; - const soClient = fleetContext.epm.internalSoClient; + const soClient = fleetContext.internalSoClient; if (request.query.kubernetes === true) { try { @@ -332,7 +332,7 @@ export const downloadFullAgentPolicy: FleetRequestHandler< TypeOf > = async (context, request, response) => { const fleetContext = await context.fleet; - const soClient = fleetContext.epm.internalSoClient; + const soClient = fleetContext.internalSoClient; const { params: { agentPolicyId }, } = request; diff --git a/x-pack/plugins/fleet/server/routes/agent_policy/index.ts b/x-pack/plugins/fleet/server/routes/agent_policy/index.ts index 938d8b3332b7..7c9726227f43 100644 --- a/x-pack/plugins/fleet/server/routes/agent_policy/index.ts +++ b/x-pack/plugins/fleet/server/routes/agent_policy/index.ts @@ -5,6 +5,8 @@ * 2.0. */ +import type { FleetAuthzRouter } from '../../services/security'; + import { AGENT_POLICY_API_ROUTES } from '../../constants'; import { GetAgentPoliciesRequestSchema, @@ -17,7 +19,6 @@ import { GetK8sManifestRequestSchema, BulkGetAgentPoliciesRequestSchema, } from '../../types'; -import type { FleetAuthzRouter } from '../security'; import { K8S_API_ROUTES } from '../../../common/constants'; diff --git a/x-pack/plugins/fleet/server/routes/app/index.ts b/x-pack/plugins/fleet/server/routes/app/index.ts index dd16c34ede80..cb0c12f9211a 100644 --- a/x-pack/plugins/fleet/server/routes/app/index.ts +++ b/x-pack/plugins/fleet/server/routes/app/index.ts @@ -8,11 +8,12 @@ import type { RequestHandler } from '@kbn/core/server'; import type { TypeOf } from '@kbn/config-schema'; +import type { FleetAuthzRouter } from '../../services/security'; + import { APP_API_ROUTES } from '../../constants'; import { appContextService } from '../../services'; import type { CheckPermissionsResponse, GenerateServiceTokenResponse } from '../../../common/types'; import { defaultFleetErrorHandler, GenerateServiceTokenError } from '../../errors'; -import type { FleetAuthzRouter } from '../security'; import type { FleetRequestHandler } from '../../types'; import { CheckPermissionsRequestSchema } from '../../types'; @@ -90,7 +91,6 @@ export const registerRoutes = (router: FleetAuthzRouter) => { { path: APP_API_ROUTES.CHECK_PERMISSIONS_PATTERN, validate: CheckPermissionsRequestSchema, - options: { tags: [] }, }, getCheckPermissionsHandler ); diff --git a/x-pack/plugins/fleet/server/routes/data_streams/index.ts b/x-pack/plugins/fleet/server/routes/data_streams/index.ts index ddefc537ba20..861ade877192 100644 --- a/x-pack/plugins/fleet/server/routes/data_streams/index.ts +++ b/x-pack/plugins/fleet/server/routes/data_streams/index.ts @@ -5,8 +5,9 @@ * 2.0. */ +import type { FleetAuthzRouter } from '../../services/security'; + import { DATA_STREAM_API_ROUTES } from '../../constants'; -import type { FleetAuthzRouter } from '../security'; import { getListHandler } from './handlers'; diff --git a/x-pack/plugins/fleet/server/routes/download_source/index.tsx b/x-pack/plugins/fleet/server/routes/download_source/index.tsx index 1c670bdf2b01..135cbf570078 100644 --- a/x-pack/plugins/fleet/server/routes/download_source/index.tsx +++ b/x-pack/plugins/fleet/server/routes/download_source/index.tsx @@ -5,6 +5,8 @@ * 2.0. */ +import type { FleetAuthzRouter } from '../../services/security'; + import { DOWNLOAD_SOURCE_API_ROUTES } from '../../constants'; import { getDownloadSourcesRequestSchema, @@ -13,7 +15,6 @@ import { PostDownloadSourcesRequestSchema, DeleteDownloadSourcesRequestSchema, } from '../../types'; -import type { FleetAuthzRouter } from '../security'; import { getDownloadSourcesHandler, diff --git a/x-pack/plugins/fleet/server/routes/enrollment_api_key/index.ts b/x-pack/plugins/fleet/server/routes/enrollment_api_key/index.ts index d904ab60e19e..496ec7ae705c 100644 --- a/x-pack/plugins/fleet/server/routes/enrollment_api_key/index.ts +++ b/x-pack/plugins/fleet/server/routes/enrollment_api_key/index.ts @@ -5,6 +5,8 @@ * 2.0. */ +import type { FleetAuthzRouter } from '../../services/security'; + import { ENROLLMENT_API_KEY_ROUTES } from '../../constants'; import { GetEnrollmentAPIKeysRequestSchema, @@ -12,7 +14,6 @@ import { DeleteEnrollmentAPIKeyRequestSchema, PostEnrollmentAPIKeyRequestSchema, } from '../../types'; -import type { FleetAuthzRouter } from '../security'; import { getEnrollmentApiKeysHandler, diff --git a/x-pack/plugins/fleet/server/routes/epm/handlers.ts b/x-pack/plugins/fleet/server/routes/epm/handlers.ts index e66bfa473edb..b4b189d3ad2b 100644 --- a/x-pack/plugins/fleet/server/routes/epm/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/epm/handlers.ts @@ -86,7 +86,7 @@ export const getListHandler: FleetRequestHandler< TypeOf > = async (context, request, response) => { try { - const savedObjectsClient = (await context.fleet).epm.internalSoClient; + const savedObjectsClient = (await context.fleet).internalSoClient; const res = await getPackages({ savedObjectsClient, ...request.query, @@ -112,7 +112,7 @@ export const getLimitedListHandler: FleetRequestHandler< undefined > = async (context, request, response) => { try { - const savedObjectsClient = (await context.fleet).epm.internalSoClient; + const savedObjectsClient = (await context.fleet).internalSoClient; const res = await getLimitedPackages({ savedObjectsClient, prerelease: request.query.prerelease, @@ -134,7 +134,7 @@ export const getFileHandler: FleetRequestHandler< > = async (context, request, response) => { try { const { pkgName, pkgVersion, filePath } = request.params; - const savedObjectsClient = (await context.fleet).epm.internalSoClient; + const savedObjectsClient = (await context.fleet).internalSoClient; const installation = await getInstallation({ savedObjectsClient, pkgName }); const useLocalFile = pkgVersion === installation?.version; @@ -208,7 +208,7 @@ export const getInfoHandler: FleetRequestHandler< TypeOf > = async (context, request, response) => { try { - const savedObjectsClient = (await context.fleet).epm.internalSoClient; + const savedObjectsClient = (await context.fleet).internalSoClient; const { pkgName, pkgVersion } = request.params; const { ignoreUnverified = false, full = false, prerelease } = request.query; if (pkgVersion && !semverValid(pkgVersion)) { @@ -237,7 +237,7 @@ export const updatePackageHandler: FleetRequestHandler< TypeOf > = async (context, request, response) => { try { - const savedObjectsClient = (await context.fleet).epm.internalSoClient; + const savedObjectsClient = (await context.fleet).internalSoClient; const { pkgName } = request.params; const res = await updatePackage({ savedObjectsClient, pkgName, ...request.body }); @@ -256,7 +256,7 @@ export const getStatsHandler: FleetRequestHandler< > = async (context, request, response) => { try { const { pkgName } = request.params; - const savedObjectsClient = (await context.fleet).epm.internalSoClient; + const savedObjectsClient = (await context.fleet).internalSoClient; const body: GetStatsResponse = { response: await getPackageUsageStats({ savedObjectsClient, pkgName }), }; @@ -273,7 +273,7 @@ export const installPackageFromRegistryHandler: FleetRequestHandler< > = async (context, request, response) => { const coreContext = await context.core; const fleetContext = await context.fleet; - const savedObjectsClient = fleetContext.epm.internalSoClient; + const savedObjectsClient = fleetContext.internalSoClient; const esClient = coreContext.elasticsearch.client.asInternalUser; const { pkgName, pkgVersion } = request.params; @@ -323,7 +323,7 @@ export const bulkInstallPackagesFromRegistryHandler: FleetRequestHandler< > = async (context, request, response) => { const coreContext = await context.core; const fleetContext = await context.fleet; - const savedObjectsClient = fleetContext.epm.internalSoClient; + const savedObjectsClient = fleetContext.internalSoClient; const esClient = coreContext.elasticsearch.client.asInternalUser; const spaceId = fleetContext.spaceId; const bulkInstalledResponses = await bulkInstallPackages({ @@ -354,7 +354,7 @@ export const installPackageByUploadHandler: FleetRequestHandler< } const coreContext = await context.core; const fleetContext = await context.fleet; - const savedObjectsClient = fleetContext.epm.internalSoClient; + const savedObjectsClient = fleetContext.internalSoClient; const esClient = coreContext.elasticsearch.client.asInternalUser; const contentType = request.headers['content-type'] as string; // from types it could also be string[] or undefined but this is checked later const archiveBuffer = Buffer.from(request.body); @@ -390,7 +390,7 @@ export const deletePackageHandler: FleetRequestHandler< const { pkgName, pkgVersion } = request.params; const coreContext = await context.core; const fleetContext = await context.fleet; - const savedObjectsClient = fleetContext.epm.internalSoClient; + const savedObjectsClient = fleetContext.internalSoClient; const esClient = coreContext.elasticsearch.client.asInternalUser; const res = await removeInstallation({ savedObjectsClient, diff --git a/x-pack/plugins/fleet/server/routes/epm/index.ts b/x-pack/plugins/fleet/server/routes/epm/index.ts index cab94b37f398..383f9c3116d8 100644 --- a/x-pack/plugins/fleet/server/routes/epm/index.ts +++ b/x-pack/plugins/fleet/server/routes/epm/index.ts @@ -7,6 +7,8 @@ import type { IKibanaResponse } from '@kbn/core/server'; +import type { FleetAuthzRouter } from '../../services/security'; + import type { DeletePackageResponse, GetInfoResponse, @@ -32,7 +34,6 @@ import { UpdatePackageRequestSchema, UpdatePackageRequestSchemaDeprecated, } from '../../types'; -import type { FleetAuthzRouter } from '../security'; import { getCategoriesHandler, diff --git a/x-pack/plugins/fleet/server/routes/fleet_proxies/index.ts b/x-pack/plugins/fleet/server/routes/fleet_proxies/index.ts index bca9d465a2fd..079b475e4f53 100644 --- a/x-pack/plugins/fleet/server/routes/fleet_proxies/index.ts +++ b/x-pack/plugins/fleet/server/routes/fleet_proxies/index.ts @@ -4,6 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import type { FleetAuthzRouter } from '../../services/security'; + import { FLEET_PROXY_API_ROUTES } from '../../../common/constants'; import { GetOneFleetProxyRequestSchema, @@ -11,8 +13,6 @@ import { PutFleetProxyRequestSchema, } from '../../types'; -import type { FleetAuthzRouter } from '../security'; - import { getAllFleetProxyHandler, postFleetProxyHandler, diff --git a/x-pack/plugins/fleet/server/routes/fleet_server_policy_config/index.ts b/x-pack/plugins/fleet/server/routes/fleet_server_policy_config/index.ts index 48607c5df7a7..f4f4837554d8 100644 --- a/x-pack/plugins/fleet/server/routes/fleet_server_policy_config/index.ts +++ b/x-pack/plugins/fleet/server/routes/fleet_server_policy_config/index.ts @@ -5,6 +5,8 @@ * 2.0. */ +import type { FleetAuthzRouter } from '../../services/security'; + import { FLEET_SERVER_HOST_API_ROUTES } from '../../../common/constants'; import { GetAllFleetServerHostRequestSchema, @@ -13,8 +15,6 @@ import { PutFleetServerHostRequestSchema, } from '../../types'; -import type { FleetAuthzRouter } from '../security'; - import { deleteFleetServerPolicyHandler, getAllFleetServerPolicyHandler, diff --git a/x-pack/plugins/fleet/server/routes/health_check/index.ts b/x-pack/plugins/fleet/server/routes/health_check/index.ts index 61b42b3bc2aa..55bd4bf7029e 100644 --- a/x-pack/plugins/fleet/server/routes/health_check/index.ts +++ b/x-pack/plugins/fleet/server/routes/health_check/index.ts @@ -9,9 +9,10 @@ import https from 'https'; import type { TypeOf } from '@kbn/config-schema'; import fetch from 'node-fetch'; +import type { FleetAuthzRouter } from '../../services/security'; + import { APP_API_ROUTES } from '../../constants'; import type { FleetRequestHandler } from '../../types'; -import type { FleetAuthzRouter } from '../security'; import { defaultFleetErrorHandler } from '../../errors'; import { PostHealthCheckRequestSchema } from '../../types'; diff --git a/x-pack/plugins/fleet/server/routes/index.ts b/x-pack/plugins/fleet/server/routes/index.ts index 5ecbb3eaad80..b5f82f992353 100644 --- a/x-pack/plugins/fleet/server/routes/index.ts +++ b/x-pack/plugins/fleet/server/routes/index.ts @@ -5,6 +5,8 @@ * 2.0. */ +import type { FleetAuthzRouter } from '../services/security'; + import type { FleetConfigType } from '../config'; import { registerRoutes as registerAgentPolicyRoutes } from './agent_policy'; @@ -22,7 +24,6 @@ import { registerRoutes as registerDownloadSourcesRoutes } from './download_sour import { registerRoutes as registerHealthCheckRoutes } from './health_check'; import { registerRoutes as registerFleetServerHostRoutes } from './fleet_server_policy_config'; import { registerRoutes as registerFleetProxiesRoutes } from './fleet_proxies'; -import type { FleetAuthzRouter } from './security'; export async function registerRoutes(fleetAuthzRouter: FleetAuthzRouter, config: FleetConfigType) { // Always register app routes for permissions checking diff --git a/x-pack/plugins/fleet/server/routes/output/index.ts b/x-pack/plugins/fleet/server/routes/output/index.ts index f74c1bb88aeb..827fca7c9444 100644 --- a/x-pack/plugins/fleet/server/routes/output/index.ts +++ b/x-pack/plugins/fleet/server/routes/output/index.ts @@ -5,6 +5,8 @@ * 2.0. */ +import type { FleetAuthzRouter } from '../../services/security'; + import { OUTPUT_API_ROUTES } from '../../constants'; import { DeleteOutputRequestSchema, @@ -13,7 +15,6 @@ import { PostOutputRequestSchema, PutOutputRequestSchema, } from '../../types'; -import type { FleetAuthzRouter } from '../security'; import { deleteOutputHandler, diff --git a/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts b/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts index 13beda40e885..5ade3dd8e169 100644 --- a/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts +++ b/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts @@ -9,6 +9,8 @@ import { httpServerMock, httpServiceMock } from '@kbn/core/server/mocks'; import type { KibanaRequest } from '@kbn/core/server'; import type { RouteConfig } from '@kbn/core/server'; +import type { FleetAuthzRouter } from '../../services/security'; + import { PACKAGE_POLICY_API_ROUTES } from '../../../common/constants'; import { appContextService, packagePolicyService } from '../../services'; import { createAppContextStartContractMock, xpackMocks } from '../../mocks'; @@ -22,7 +24,6 @@ import type { CreatePackagePolicyRequestSchema, UpdatePackagePolicyRequestSchema, } from '../../types/rest_spec'; -import type { FleetAuthzRouter } from '../security'; import type { FleetRequestHandler } from '../../types'; import type { PackagePolicy } from '../../types'; diff --git a/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts b/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts index ee9caa4def67..ab3af0cb0a16 100644 --- a/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts @@ -43,16 +43,72 @@ import { simplifiedPackagePolicytoNewPackagePolicy } from '../../../common/servi import type { SimplifiedPackagePolicy } from '../../../common/services/simplified_package_policy_helper'; -export const getPackagePoliciesHandler: RequestHandler< +const getAllowedPackageNamesMessage = (allowedPackageNames: string[]): string => { + return `Allowed package.name's: ${allowedPackageNames.join(', ')}`; +}; + +/** + * Validates that Package Policy data only includes `package.name`'s that are in the list of + * `allowedPackageNames`. If an error is encountered, then a message is return, otherwise, undefined. + * + * @param data + * @param allowedPackageNames + */ +const validatePackagePolicyDataIsScopedToAllowedPackageNames = ( + data: PackagePolicy[], + allowedPackageNames: string[] | undefined +): string | undefined => { + if (!data.length || typeof allowedPackageNames === 'undefined') { + return; + } + + if (!allowedPackageNames.length) { + return 'Authorization denied due to lack of integration package privileges'; + } + + // Because List type of APIs have an un-bounded `perPage` query param, we only validate the + // data up to the first package.name that we find is not authorized. + for (const packagePolicy of data) { + if (!packagePolicy.package) { + return `Authorization denied. ${getAllowedPackageNamesMessage(allowedPackageNames)}`; + } + + if (!allowedPackageNames.includes(packagePolicy.package.name)) { + return `Authorization denied to [package.name=${ + packagePolicy.package.name + }]. ${getAllowedPackageNamesMessage(allowedPackageNames)}`; + } + } +}; + +export const getPackagePoliciesHandler: FleetRequestHandler< undefined, TypeOf > = async (context, request, response) => { - const soClient = (await context.core).savedObjects.client; + const fleetContext = await context.fleet; + const soClient = fleetContext.internalSoClient; + const limitedToPackages = fleetContext.limitedToPackages; + try { const { items, total, page, perPage } = await packagePolicyService.list( soClient, request.query ); + + // specific to package-level RBAC + const validationResult = validatePackagePolicyDataIsScopedToAllowedPackageNames( + items, + limitedToPackages + ); + if (validationResult) { + return response.forbidden({ + body: { + message: validationResult, + }, + }); + } + + // agnostic to package-level RBAC return response.ok({ body: { items, @@ -66,13 +122,16 @@ export const getPackagePoliciesHandler: RequestHandler< } }; -export const bulkGetPackagePoliciesHandler: RequestHandler< +export const bulkGetPackagePoliciesHandler: FleetRequestHandler< undefined, undefined, TypeOf > = async (context, request, response) => { - const soClient = (await context.core).savedObjects.client; + const fleetContext = await context.fleet; + const soClient = fleetContext.internalSoClient; + const limitedToPackages = fleetContext.limitedToPackages; const { ids, ignoreMissing } = request.body; + try { const items = await packagePolicyService.getByIDs(soClient, ids, { ignoreMissing, @@ -80,6 +139,18 @@ export const bulkGetPackagePoliciesHandler: RequestHandler< const body: BulkGetPackagePoliciesResponse = { items: items ?? [] }; + const validationResult = validatePackagePolicyDataIsScopedToAllowedPackageNames( + body.items, + limitedToPackages + ); + if (validationResult) { + return response.forbidden({ + body: { + message: validationResult, + }, + }); + } + return response.ok({ body, }); @@ -94,17 +165,32 @@ export const bulkGetPackagePoliciesHandler: RequestHandler< } }; -export const getOnePackagePolicyHandler: RequestHandler< +export const getOnePackagePolicyHandler: FleetRequestHandler< TypeOf > = async (context, request, response) => { - const soClient = (await context.core).savedObjects.client; + const fleetContext = await context.fleet; + const soClient = fleetContext.internalSoClient; + const limitedToPackages = fleetContext.limitedToPackages; const { packagePolicyId } = request.params; const notFoundResponse = () => response.notFound({ body: { message: `Package policy ${packagePolicyId} not found` } }); try { const packagePolicy = await packagePolicyService.get(soClient, packagePolicyId); + if (packagePolicy) { + const validationResult = validatePackagePolicyDataIsScopedToAllowedPackageNames( + [packagePolicy], + limitedToPackages + ); + if (validationResult) { + return response.forbidden({ + body: { + message: validationResult, + }, + }); + } + return response.ok({ body: { item: packagePolicy, @@ -184,7 +270,7 @@ export const createPackagePolicyHandler: FleetRequestHandler< > = async (context, request, response) => { const coreContext = await context.core; const fleetContext = await context.fleet; - const soClient = fleetContext.epm.internalSoClient; + const soClient = fleetContext.internalSoClient; const esClient = coreContext.elasticsearch.client.asInternalUser; const user = appContextService.getSecurity()?.authc.getCurrentUser(request) || undefined; const { force, package: pkg, ...newPolicy } = request.body; @@ -258,13 +344,15 @@ export const createPackagePolicyHandler: FleetRequestHandler< } }; -export const updatePackagePolicyHandler: RequestHandler< +export const updatePackagePolicyHandler: FleetRequestHandler< TypeOf, unknown, TypeOf > = async (context, request, response) => { const coreContext = await context.core; - const soClient = coreContext.savedObjects.client; + const fleetContext = await context.fleet; + const soClient = fleetContext.internalSoClient; + const limitedToPackages = fleetContext.limitedToPackages; const esClient = coreContext.elasticsearch.client.asInternalUser; const user = appContextService.getSecurity()?.authc.getCurrentUser(request) || undefined; const packagePolicy = await packagePolicyService.get(soClient, request.params.packagePolicyId); @@ -273,6 +361,15 @@ export const updatePackagePolicyHandler: RequestHandler< throw Boom.notFound('Package policy not found'); } + if (limitedToPackages && limitedToPackages.length) { + const packageName = packagePolicy?.package?.name; + if (packageName && !limitedToPackages.includes(packageName)) { + return response.forbidden({ + body: { message: `Update for package name ${packageName} is not authorized.` }, + }); + } + } + try { const { force, package: pkg, ...body } = request.body; // TODO Remove deprecated APIs https://github.com/elastic/kibana/issues/121485 diff --git a/x-pack/plugins/fleet/server/routes/package_policy/index.ts b/x-pack/plugins/fleet/server/routes/package_policy/index.ts index 00d06363968f..393bbb2fd982 100644 --- a/x-pack/plugins/fleet/server/routes/package_policy/index.ts +++ b/x-pack/plugins/fleet/server/routes/package_policy/index.ts @@ -5,6 +5,11 @@ * 2.0. */ +import { getRouteRequiredAuthz } from '../../services/security'; + +import type { FleetAuthzRouter } from '../../services/security'; + +import type { FleetAuthz } from '../../../common'; import { PACKAGE_POLICY_API_ROUTES } from '../../constants'; import { GetPackagePoliciesRequestSchema, @@ -17,7 +22,7 @@ import { DeleteOnePackagePolicyRequestSchema, BulkGetPackagePoliciesRequestSchema, } from '../../types'; -import type { FleetAuthzRouter } from '../security'; +import { calculateRouteAuthz } from '../../services/security/security'; import { getPackagePoliciesHandler, @@ -38,20 +43,25 @@ export const registerRoutes = (router: FleetAuthzRouter) => { { path: PACKAGE_POLICY_API_ROUTES.LIST_PATTERN, validate: GetPackagePoliciesRequestSchema, - fleetAuthz: { - integrations: { readIntegrationPolicies: true }, - }, + fleetAuthz: (fleetAuthz: FleetAuthz): boolean => + calculateRouteAuthz( + fleetAuthz, + getRouteRequiredAuthz('get', PACKAGE_POLICY_API_ROUTES.LIST_PATTERN) + ).granted, }, getPackagePoliciesHandler ); + // Get bulk router.post( { path: PACKAGE_POLICY_API_ROUTES.BULK_GET_PATTERN, validate: BulkGetPackagePoliciesRequestSchema, - fleetAuthz: { - integrations: { readIntegrationPolicies: true }, - }, + fleetAuthz: (fleetAuthz: FleetAuthz): boolean => + calculateRouteAuthz( + fleetAuthz, + getRouteRequiredAuthz('post', PACKAGE_POLICY_API_ROUTES.BULK_GET_PATTERN) + ).granted, }, bulkGetPackagePoliciesHandler ); @@ -61,9 +71,11 @@ export const registerRoutes = (router: FleetAuthzRouter) => { { path: PACKAGE_POLICY_API_ROUTES.INFO_PATTERN, validate: GetOnePackagePolicyRequestSchema, - fleetAuthz: { - integrations: { readIntegrationPolicies: true }, - }, + fleetAuthz: (fleetAuthz: FleetAuthz): boolean => + calculateRouteAuthz( + fleetAuthz, + getRouteRequiredAuthz('get', PACKAGE_POLICY_API_ROUTES.INFO_PATTERN) + ).granted, }, getOnePackagePolicyHandler ); @@ -93,14 +105,16 @@ export const registerRoutes = (router: FleetAuthzRouter) => { { path: PACKAGE_POLICY_API_ROUTES.UPDATE_PATTERN, validate: UpdatePackagePolicyRequestSchema, - fleetAuthz: { - integrations: { writeIntegrationPolicies: true }, - }, + fleetAuthz: (fleetAuthz: FleetAuthz): boolean => + calculateRouteAuthz( + fleetAuthz, + getRouteRequiredAuthz('put', PACKAGE_POLICY_API_ROUTES.UPDATE_PATTERN) + ).granted, }, updatePackagePolicyHandler ); - // Delete + // Delete (bulk) router.post( { path: PACKAGE_POLICY_API_ROUTES.DELETE_PATTERN, diff --git a/x-pack/plugins/fleet/server/routes/preconfiguration/index.ts b/x-pack/plugins/fleet/server/routes/preconfiguration/index.ts index 9f5f37229853..5d427b49bed6 100644 --- a/x-pack/plugins/fleet/server/routes/preconfiguration/index.ts +++ b/x-pack/plugins/fleet/server/routes/preconfiguration/index.ts @@ -5,9 +5,10 @@ * 2.0. */ +import type { FleetAuthzRouter } from '../../services/security'; + import { PRECONFIGURATION_API_ROUTES } from '../../constants'; import { PostResetOnePreconfiguredAgentPoliciesSchema } from '../../types'; -import type { FleetAuthzRouter } from '../security'; import { resetPreconfigurationHandler, resetOnePreconfigurationHandler } from './handler'; diff --git a/x-pack/plugins/fleet/server/routes/security.ts b/x-pack/plugins/fleet/server/routes/security.ts deleted file mode 100644 index 8914be58294b..000000000000 --- a/x-pack/plugins/fleet/server/routes/security.ts +++ /dev/null @@ -1,297 +0,0 @@ -/* - * 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 { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common'; -import type { - IRouter, - RouteConfig, - RouteMethod, - KibanaRequest, - RequestHandler, - RequestHandlerContext, - OnPostAuthHandler, -} from '@kbn/core/server'; - -import type { FleetAuthz } from '../../common'; -import { INTEGRATIONS_PLUGIN_ID } from '../../common'; -import { calculateAuthz, calculatePackagePrivilegesFromKibanaPrivileges } from '../../common/authz'; - -import { appContextService } from '../services'; -import type { FleetRequestHandlerContext } from '../types'; -import { PLUGIN_ID, ENDPOINT_PRIVILEGES } from '../constants'; - -function checkSecurityEnabled() { - return appContextService.getSecurityLicense().isEnabled(); -} - -export function checkSuperuser(req: KibanaRequest) { - if (!checkSecurityEnabled()) { - return false; - } - - const security = appContextService.getSecurity(); - const user = security.authc.getCurrentUser(req); - if (!user) { - return false; - } - - const userRoles = user.roles || []; - if (!userRoles.includes('superuser')) { - return false; - } - - return true; -} - -function getAuthorizationFromPrivileges( - kibanaPrivileges: Array<{ - resource?: string; - privilege: string; - authorized: boolean; - }>, - searchPrivilege: string -) { - const privilege = kibanaPrivileges.find((p) => p.privilege.includes(searchPrivilege)); - return privilege ? privilege.authorized : false; -} - -export async function getAuthzFromRequest(req: KibanaRequest): Promise { - const security = appContextService.getSecurity(); - - if (security.authz.mode.useRbacForRequest(req)) { - const checkPrivileges = security.authz.checkPrivilegesDynamicallyWithRequest(req); - const endpointPrivileges = ENDPOINT_PRIVILEGES.map((privilege) => - security.authz.actions.api.get(`${DEFAULT_APP_CATEGORIES.security.id}-${privilege}`) - ); - const { privileges } = await checkPrivileges({ - kibana: [ - security.authz.actions.api.get(`${PLUGIN_ID}-all`), - security.authz.actions.api.get(`${PLUGIN_ID}-setup`), - security.authz.actions.api.get(`${INTEGRATIONS_PLUGIN_ID}-all`), - security.authz.actions.api.get(`${INTEGRATIONS_PLUGIN_ID}-read`), - ...endpointPrivileges, - ], - }); - const fleetAllAuth = getAuthorizationFromPrivileges(privileges.kibana, `${PLUGIN_ID}-all`); - const intAllAuth = getAuthorizationFromPrivileges( - privileges.kibana, - `${INTEGRATIONS_PLUGIN_ID}-all` - ); - const intReadAuth = getAuthorizationFromPrivileges( - privileges.kibana, - `${INTEGRATIONS_PLUGIN_ID}-read` - ); - const fleetSetupAuth = getAuthorizationFromPrivileges(privileges.kibana, 'fleet-setup'); - - return { - ...calculateAuthz({ - fleet: { all: fleetAllAuth, setup: fleetSetupAuth }, - integrations: { - all: intAllAuth, - read: intReadAuth, - }, - isSuperuser: checkSuperuser(req), - }), - packagePrivileges: calculatePackagePrivilegesFromKibanaPrivileges(privileges.kibana), - }; - } - - return calculateAuthz({ - fleet: { all: false, setup: false }, - integrations: { - all: false, - read: false, - }, - isSuperuser: false, - }); -} - -interface Authz { - [k: string]: Authz | boolean; -} - -function containsRequirement(authz: Authz, requirements: DeepPartialTruthy) { - if (!authz) { - return false; - } - for (const key of Object.keys(requirements)) { - if (typeof requirements[key] !== 'undefined' && typeof requirements[key] === 'boolean') { - if (!authz[key]) { - return false; - } - } else if ( - !containsRequirement(authz[key] as Authz, requirements[key] as DeepPartialTruthy) - ) { - return false; - } - } - return true; -} - -export function hasRequiredFleetAuthzPrivilege( - authz: FleetAuthz, - { fleetAuthz }: { fleetAuthz?: FleetAuthzRequirements } -): boolean { - if (!checkSecurityEnabled()) { - return false; - } - if (fleetAuthz && !containsRequirement(authz as unknown as Authz, fleetAuthz)) { - return false; - } - - return true; -} - -type DeepPartialTruthy = { - [P in keyof T]?: T[P] extends boolean ? true : DeepPartialTruthy; -}; - -type FleetAuthzRequirements = DeepPartialTruthy; - -type FleetAuthzRouteRegistrar< - Method extends RouteMethod, - Context extends RequestHandlerContext = RequestHandlerContext -> = ( - route: FleetRouteConfig, - handler: RequestHandler -) => void; - -export interface FleetAuthzRouteConfig { - fleetAuthz?: FleetAuthzRequirements; -} - -type FleetRouteConfig = RouteConfig & - FleetAuthzRouteConfig; - -// Fleet router that allow to add required access when registering route -export interface FleetAuthzRouter< - TContext extends FleetRequestHandlerContext = FleetRequestHandlerContext -> extends IRouter { - get: FleetAuthzRouteRegistrar<'get', TContext>; - delete: FleetAuthzRouteRegistrar<'delete', TContext>; - post: FleetAuthzRouteRegistrar<'post', TContext>; - put: FleetAuthzRouteRegistrar<'put', TContext>; - patch: FleetAuthzRouteRegistrar<'patch', TContext>; -} - -function shouldHandlePostAuthRequest(req: KibanaRequest) { - if (req?.route?.options?.tags) { - return req.route.options.tags.some((tag) => tag.match(/^fleet:authz/)); - } - return false; -} -// Exported for test only -export function deserializeAuthzConfig(tags: readonly string[]): FleetAuthzRouteConfig { - let fleetAuthz: FleetAuthzRequirements | undefined; - for (const tag of tags) { - if (!tag.match(/^fleet:authz/)) { - continue; - } - - if (!fleetAuthz) { - fleetAuthz = {}; - } - - tag - .replace(/^fleet:authz:/, '') - .split(':') - .reduce((acc: any, key, idx, keys) => { - if (idx === keys.length - 1) { - acc[key] = true; - - return acc; - } - - if (!acc[key]) { - acc[key] = {}; - } - - return acc[key]; - }, fleetAuthz); - } - - return { fleetAuthz }; -} - -// Exported for test only -export function serializeAuthzConfig(config: FleetAuthzRouteConfig): string[] { - const tags: string[] = []; - - if (config.fleetAuthz) { - function fleetAuthzToTags(requirements: DeepPartialTruthy, prefix: string = '') { - for (const key of Object.keys(requirements)) { - if (typeof requirements[key] === 'boolean') { - tags.push(`fleet:authz:${prefix}${key}`); - } else if (typeof requirements[key] !== 'undefined') { - fleetAuthzToTags(requirements[key] as DeepPartialTruthy, `${prefix}${key}:`); - } - } - } - - fleetAuthzToTags(config.fleetAuthz); - } - - return tags; -} - -export function makeRouterWithFleetAuthz( - router: IRouter -): { router: FleetAuthzRouter; onPostAuthHandler: OnPostAuthHandler } { - function buildFleetAuthzRouteConfig({ - fleetAuthz, - ...routeConfig - }: FleetRouteConfig) { - return { - ...routeConfig, - options: { - ...routeConfig.options, - tags: [ - ...(routeConfig?.options?.tags ?? []), - ...serializeAuthzConfig({ - fleetAuthz, - }), - ], - }, - }; - } - - const fleetAuthzOnPostAuthHandler: OnPostAuthHandler = async (req, res, toolkit) => { - if (!shouldHandlePostAuthRequest(req)) { - return toolkit.next(); - } - - if (!checkSecurityEnabled()) { - return res.forbidden(); - } - - const fleetAuthzConfig = deserializeAuthzConfig(req.route.options.tags); - - if (!fleetAuthzConfig) { - return toolkit.next(); - } - const authz = await getAuthzFromRequest(req); - if (!hasRequiredFleetAuthzPrivilege(authz, fleetAuthzConfig)) { - return res.forbidden(); - } - - return toolkit.next(); - }; - - const fleetAuthzRouter: FleetAuthzRouter = { - get: (routeConfig, handler) => router.get(buildFleetAuthzRouteConfig(routeConfig), handler), - delete: (routeConfig, handler) => - router.delete(buildFleetAuthzRouteConfig(routeConfig), handler), - post: (routeConfig, handler) => router.post(buildFleetAuthzRouteConfig(routeConfig), handler), - put: (routeConfig, handler) => router.put(buildFleetAuthzRouteConfig(routeConfig), handler), - patch: (routeConfig, handler) => router.patch(buildFleetAuthzRouteConfig(routeConfig), handler), - handleLegacyErrors: (handler) => router.handleLegacyErrors(handler), - getRoutes: () => router.getRoutes(), - routerPath: router.routerPath, - }; - - return { router: fleetAuthzRouter, onPostAuthHandler: fleetAuthzOnPostAuthHandler }; -} diff --git a/x-pack/plugins/fleet/server/routes/settings/index.ts b/x-pack/plugins/fleet/server/routes/settings/index.ts index f11244d7b59f..881541b56980 100644 --- a/x-pack/plugins/fleet/server/routes/settings/index.ts +++ b/x-pack/plugins/fleet/server/routes/settings/index.ts @@ -7,15 +7,16 @@ import type { TypeOf } from '@kbn/config-schema'; +import type { FleetAuthzRouter } from '../../services/security'; + import { SETTINGS_API_ROUTES } from '../../constants'; import type { FleetRequestHandler } from '../../types'; import { PutSettingsRequestSchema, GetSettingsRequestSchema } from '../../types'; import { defaultFleetErrorHandler } from '../../errors'; import { settingsService, agentPolicyService, appContextService } from '../../services'; -import type { FleetAuthzRouter } from '../security'; export const getSettingsHandler: FleetRequestHandler = async (context, request, response) => { - const soClient = (await context.fleet).epm.internalSoClient; + const soClient = (await context.fleet).internalSoClient; try { const settings = await settingsService.getSettings(soClient); @@ -39,7 +40,7 @@ export const putSettingsHandler: FleetRequestHandler< undefined, TypeOf > = async (context, request, response) => { - const soClient = (await context.fleet).epm.internalSoClient; + const soClient = (await context.fleet).internalSoClient; const esClient = (await context.core).elasticsearch.client.asInternalUser; const user = await appContextService.getSecurity()?.authc.getCurrentUser(request); diff --git a/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts b/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts index 0ecbca40132c..f4b35508e52c 100644 --- a/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts +++ b/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts @@ -51,10 +51,9 @@ describe('FleetSetupHandler', () => { asCurrentUser: createPackagePolicyServiceMock(), asInternalUser: createPackagePolicyServiceMock(), }, - epm: { - internalSoClient: savedObjectsClientMock.create(), - }, + internalSoClient: savedObjectsClientMock.create(), spaceId: 'default', + limitedToPackages: undefined, }, }; response = httpServerMock.createResponseFactory(); diff --git a/x-pack/plugins/fleet/server/routes/setup/handlers.ts b/x-pack/plugins/fleet/server/routes/setup/handlers.ts index cf2ff46cd111..78daddf837ac 100644 --- a/x-pack/plugins/fleet/server/routes/setup/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/setup/handlers.ts @@ -60,7 +60,7 @@ export const getFleetStatusHandler: FleetRequestHandler = async (context, reques export const fleetSetupHandler: FleetRequestHandler = async (context, request, response) => { try { - const soClient = (await context.fleet).epm.internalSoClient; + const soClient = (await context.fleet).internalSoClient; const esClient = (await context.core).elasticsearch.client.asInternalUser; const setupStatus = await setupFleet(soClient, esClient); const body: PostFleetSetupResponse = { diff --git a/x-pack/plugins/fleet/server/routes/setup/index.ts b/x-pack/plugins/fleet/server/routes/setup/index.ts index 8b2aa2bf8f57..b4470e648dca 100644 --- a/x-pack/plugins/fleet/server/routes/setup/index.ts +++ b/x-pack/plugins/fleet/server/routes/setup/index.ts @@ -5,11 +5,11 @@ * 2.0. */ +import type { FleetAuthzRouter } from '../../services/security'; + import { AGENTS_SETUP_API_ROUTES, SETUP_API_ROUTE } from '../../constants'; import type { FleetConfigType } from '../../../common/types'; -import type { FleetAuthzRouter } from '../security'; - import { getFleetStatusHandler, fleetSetupHandler } from './handlers'; export const registerFleetSetupRoute = (router: FleetAuthzRouter) => { diff --git a/x-pack/plugins/fleet/server/services/agents/agent_service.test.ts b/x-pack/plugins/fleet/server/services/agents/agent_service.test.ts index be4ca37260b8..fe43fa50b431 100644 --- a/x-pack/plugins/fleet/server/services/agents/agent_service.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/agent_service.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -jest.mock('../../routes/security'); +jest.mock('../security'); jest.mock('./crud'); jest.mock('./status'); @@ -14,7 +14,7 @@ import { elasticsearchServiceMock, httpServerMock } from '@kbn/core/server/mocks import { FleetUnauthorizedError } from '../../errors'; -import { getAuthzFromRequest } from '../../routes/security'; +import { getAuthzFromRequest } from '../security'; import type { FleetAuthz } from '../../../common'; import type { AgentClient } from './agent_service'; diff --git a/x-pack/plugins/fleet/server/services/agents/agent_service.ts b/x-pack/plugins/fleet/server/services/agents/agent_service.ts index 576505e46bd2..8f732809adf5 100644 --- a/x-pack/plugins/fleet/server/services/agents/agent_service.ts +++ b/x-pack/plugins/fleet/server/services/agents/agent_service.ts @@ -12,7 +12,7 @@ import type { ElasticsearchClient, KibanaRequest } from '@kbn/core/server'; import type { AgentStatus, ListWithKuery } from '../../types'; import type { Agent, GetAgentStatusResponse } from '../../../common/types'; -import { getAuthzFromRequest } from '../../routes/security'; +import { getAuthzFromRequest } from '../security'; import { FleetUnauthorizedError } from '../../errors'; diff --git a/x-pack/plugins/fleet/server/services/epm/package_service.test.ts b/x-pack/plugins/fleet/server/services/epm/package_service.test.ts index 44d35f3e4c33..779f0dad02c8 100644 --- a/x-pack/plugins/fleet/server/services/epm/package_service.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/package_service.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -jest.mock('../../routes/security'); +jest.mock('../security'); import type { MockedLogger } from '@kbn/logging-mocks'; diff --git a/x-pack/plugins/fleet/server/services/epm/package_service.ts b/x-pack/plugins/fleet/server/services/epm/package_service.ts index f3d82f13d96e..dfc02c4f68c5 100644 --- a/x-pack/plugins/fleet/server/services/epm/package_service.ts +++ b/x-pack/plugins/fleet/server/services/epm/package_service.ts @@ -22,7 +22,7 @@ import type { ArchivePackage, BundledPackage, } from '../../types'; -import { checkSuperuser } from '../../routes/security'; +import { checkSuperuser } from '../security'; import { FleetUnauthorizedError } from '../../errors'; import { installTransforms, isTransform } from './elasticsearch/transform/install'; diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 7acd555380a6..a95cba87b39b 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -82,8 +82,9 @@ import type { } from '../types'; import type { ExternalCallback } from '..'; -import type { FleetAuthzRouteConfig } from '../routes/security'; -import { getAuthzFromRequest, hasRequiredFleetAuthzPrivilege } from '../routes/security'; +import type { FleetAuthzRouteConfig } from './security'; + +import { getAuthzFromRequest, doesNotHaveRequiredFleetAuthz } from './security'; import { storedPackagePolicyToAgentInputs } from './agent_policies'; import { agentPolicyService } from './agent_policy'; @@ -1294,12 +1295,14 @@ export class PackagePolicyServiceImpl implements PackagePolicyService { public asScoped(request: KibanaRequest): PackagePolicyClient { - const preflightCheck = async (fleetAuthzConfig: FleetAuthzRouteConfig) => { + const preflightCheck = async ({ fleetAuthz: fleetRequiredAuthz }: FleetAuthzRouteConfig) => { const authz = await getAuthzFromRequest(request); - if (!hasRequiredFleetAuthzPrivilege(authz, fleetAuthzConfig)) { + + if (doesNotHaveRequiredFleetAuthz(authz, fleetRequiredAuthz)) { throw new FleetUnauthorizedError('Not authorized to this action on integration policies'); } }; + return new PackagePolicyClientWithAuthz(preflightCheck); } diff --git a/x-pack/plugins/fleet/server/routes/security.test.ts b/x-pack/plugins/fleet/server/services/security/fleet_router.test.ts similarity index 66% rename from x-pack/plugins/fleet/server/routes/security.test.ts rename to x-pack/plugins/fleet/server/services/security/fleet_router.test.ts index 15e46529c869..1f2b6c3fab22 100644 --- a/x-pack/plugins/fleet/server/routes/security.test.ts +++ b/x-pack/plugins/fleet/server/services/security/fleet_router.test.ts @@ -5,17 +5,25 @@ * 2.0. */ +import type { CheckPrivilegesDynamically } from '@kbn/security-plugin/server/authorization/check_privileges_dynamically'; import type { IRouter, RequestHandler, RouteConfig } from '@kbn/core/server'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; + +import type { AuthenticatedUser } from '@kbn/security-plugin/common'; + import { coreMock } from '@kbn/core/server/mocks'; -import type { AuthenticatedUser, CheckPrivilegesPayload } from '@kbn/security-plugin/server'; + +import type { CheckPrivilegesPayload } from '@kbn/security-plugin/server'; + import type { CheckPrivilegesResponse } from '@kbn/security-plugin/server/authorization/types'; -import type { CheckPrivilegesDynamically } from '@kbn/security-plugin/server/authorization/check_privileges_dynamically'; -import { createAppContextStartContractMock } from '../mocks'; -import { appContextService } from '../services'; -import type { FleetRequestHandlerContext } from '../types'; +import type { FleetRequestHandlerContext } from '../..'; +import { createAppContextStartContractMock } from '../../mocks'; +import { appContextService } from '..'; + +import { makeRouterWithFleetAuthz } from './fleet_router'; -import { deserializeAuthzConfig, makeRouterWithFleetAuthz, serializeAuthzConfig } from './security'; +const mockLogger = loggingSystemMock.createLogger(); function getCheckPrivilegesMockedImplementation(kibanaRoles: string[]) { return (checkPrivileges: CheckPrivilegesPayload) => { @@ -82,12 +90,11 @@ describe('FleetAuthzRouter', () => { appContextService.start(mockContext); - const { router: wrappedRouter, onPostAuthHandler } = makeRouterWithFleetAuthz(fakeRouter); - wrappedRouter.get({ ...routeConfig } as RouteConfig, fakeHandler); + const fleetAuthzRouter = makeRouterWithFleetAuthz(fakeRouter, mockLogger); + fleetAuthzRouter.get({ ...routeConfig } as RouteConfig, fakeHandler); const wrappedHandler = fakeRouter.get.mock.calls[0][1]; const wrappedRouteConfig = fakeRouter.get.mock.calls[0][0]; const resFactory = { forbidden: jest.fn(() => 'forbidden'), ok: jest.fn(() => 'ok') }; - const fakeToolkit = { next: jest.fn(() => 'next') }; const fakeReq = { route: { @@ -96,11 +103,6 @@ describe('FleetAuthzRouter', () => { options: wrappedRouteConfig.options, }, } as any; - const onPostRes = await onPostAuthHandler(fakeReq, resFactory as any, fakeToolkit as any); - - if ((onPostRes as unknown) !== 'next') { - return onPostRes; - } const res = await wrappedHandler( { @@ -198,79 +200,3 @@ describe('FleetAuthzRouter', () => { }); }); }); - -describe('serializeAuthzConfig', () => { - it('should serialize authz to tags', () => { - const res = serializeAuthzConfig({ - fleetAuthz: { - fleet: { - readEnrollmentTokens: true, - setup: true, - }, - integrations: { - readPackageInfo: true, - removePackages: true, - }, - packagePrivileges: { - endpoint: { - actions: { - readPolicyManagement: { - executePackageAction: true, - }, - readBlocklist: { - executePackageAction: true, - }, - }, - }, - }, - }, - }); - - expect(res).toEqual([ - 'fleet:authz:fleet:readEnrollmentTokens', - 'fleet:authz:fleet:setup', - 'fleet:authz:integrations:readPackageInfo', - 'fleet:authz:integrations:removePackages', - 'fleet:authz:packagePrivileges:endpoint:actions:readPolicyManagement:executePackageAction', - 'fleet:authz:packagePrivileges:endpoint:actions:readBlocklist:executePackageAction', - ]); - }); -}); - -describe('deserializeAuthzConfig', () => { - it('should deserialize tags to fleet authz', () => { - const res = deserializeAuthzConfig([ - 'fleet:authz:fleet:readEnrollmentTokens', - 'fleet:authz:fleet:setup', - 'fleet:authz:integrations:readPackageInfo', - 'fleet:authz:integrations:removePackages', - 'fleet:authz:packagePrivileges:endpoint:actions:readPolicyManagement:executePackageAction', - 'fleet:authz:packagePrivileges:endpoint:actions:readBlocklist:executePackageAction', - ]); - - expect(res).toEqual({ - fleetAuthz: { - fleet: { - readEnrollmentTokens: true, - setup: true, - }, - integrations: { - readPackageInfo: true, - removePackages: true, - }, - packagePrivileges: { - endpoint: { - actions: { - readPolicyManagement: { - executePackageAction: true, - }, - readBlocklist: { - executePackageAction: true, - }, - }, - }, - }, - }, - }); - }); -}); diff --git a/x-pack/plugins/fleet/server/services/security/fleet_router.ts b/x-pack/plugins/fleet/server/services/security/fleet_router.ts new file mode 100644 index 000000000000..1b1d84d3aca4 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/security/fleet_router.ts @@ -0,0 +1,95 @@ +/* + * 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 { + IKibanaResponse, + IRouter, + KibanaRequest, + KibanaResponseFactory, + Logger, + RequestHandler, + RouteMethod, +} from '@kbn/core/server'; + +import type { FleetRequestHandlerContext } from '../..'; + +import type { FleetAuthzRouteConfig, FleetAuthzRouter } from './types'; +import { + checkSecurityEnabled, + getAuthzFromRequest, + doesNotHaveRequiredFleetAuthz, +} from './security'; + +export function makeRouterWithFleetAuthz( + router: IRouter, + logger: Logger +): FleetAuthzRouter { + const routerAuthzWrapper = async ({ + context, + request, + response, + handler, + hasRequiredAuthz, + }: { + context: TContext; + request: KibanaRequest; + response: KibanaResponseFactory; + handler: RequestHandler; + hasRequiredAuthz?: FleetAuthzRouteConfig['fleetAuthz']; + }): Promise> => { + if (!checkSecurityEnabled()) { + const securityEnabledInfo = 'Kibana security must be enabled to use Fleet'; + logger.info(securityEnabledInfo); + return response.forbidden({ + body: { + message: securityEnabledInfo, + }, + }); + } + + const requestedAuthz = await getAuthzFromRequest(request); + + if (doesNotHaveRequiredFleetAuthz(requestedAuthz, hasRequiredAuthz)) { + logger.info(`User does not have required fleet authz to access path: ${request.route.path}`); + return response.forbidden(); + } + return handler(context, request, response); + }; + + const fleetAuthzRouter: FleetAuthzRouter = { + get: ({ fleetAuthz: hasRequiredAuthz, ...options }, handler) => { + router.get(options, async (context, request, response) => + routerAuthzWrapper({ context, request, response, handler, hasRequiredAuthz }) + ); + }, + delete: ({ fleetAuthz: hasRequiredAuthz, ...options }, handler) => { + router.delete(options, async (context, request, response) => + routerAuthzWrapper({ context, request, response, handler, hasRequiredAuthz }) + ); + }, + post: ({ fleetAuthz: hasRequiredAuthz, ...options }, handler) => { + router.post(options, async (context, request, response) => + routerAuthzWrapper({ context, request, response, handler, hasRequiredAuthz }) + ); + }, + put: ({ fleetAuthz: hasRequiredAuthz, ...options }, handler) => { + router.put(options, async (context, request, response) => + routerAuthzWrapper({ context, request, response, handler, hasRequiredAuthz }) + ); + }, + patch: ({ fleetAuthz: hasRequiredAuthz, ...options }, handler) => { + router.patch(options, async (context, request, response) => + routerAuthzWrapper({ context, request, response, handler, hasRequiredAuthz }) + ); + }, + handleLegacyErrors: (handler) => router.handleLegacyErrors(handler), + getRoutes: () => router.getRoutes(), + routerPath: router.routerPath, + }; + + return fleetAuthzRouter; +} diff --git a/x-pack/plugins/fleet/server/services/security/index.ts b/x-pack/plugins/fleet/server/services/security/index.ts new file mode 100644 index 000000000000..c41c769c58d8 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/security/index.ts @@ -0,0 +1,17 @@ +/* + * 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 * from './types'; +export { makeRouterWithFleetAuthz } from './fleet_router'; +export { getRouteRequiredAuthz } from './route_required_authz'; +export { + checkSecurityEnabled, + checkSuperuser, + calculateRouteAuthz, + getAuthzFromRequest, + doesNotHaveRequiredFleetAuthz, +} from './security'; diff --git a/x-pack/plugins/fleet/server/services/security/route_required_authz.ts b/x-pack/plugins/fleet/server/services/security/route_required_authz.ts new file mode 100644 index 000000000000..db0ea31eff7a --- /dev/null +++ b/x-pack/plugins/fleet/server/services/security/route_required_authz.ts @@ -0,0 +1,175 @@ +/* + * 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 { deepFreeze } from '@kbn/std'; + +import type { RouteMethod } from '@kbn/core-http-server'; + +import { PACKAGE_POLICY_API_ROUTES } from '../../../common'; + +import type { FleetRouteRequiredAuthz } from './types'; + +/** + * The authorization requirements needed for an API route. Route authorization requirements are + * defined either via an `all` object, where all values must be `true` in order for access to be granted, + * or, by an `any` object, where any value defined that is set to `true` will grant access to the API. + * + * The `all` conditions are checked first and if those evaluate to `false`, then `any` conditions are evaluated. + */ +const ROUTE_AUTHZ_REQUIREMENTS = deepFreeze>({ + // Package Policy Update API + [`put:${PACKAGE_POLICY_API_ROUTES.UPDATE_PATTERN}`]: { + any: { + integrations: { writeIntegrationPolicies: true }, + packagePrivileges: { + endpoint: { + actions: { + writePolicyManagement: { + executePackageAction: true, + }, + }, + }, + }, + }, + }, + + // Package Policy GET one API + [`get:${PACKAGE_POLICY_API_ROUTES.INFO_PATTERN}`]: { + any: { + integrations: { + readIntegrationPolicies: true, + }, + packagePrivileges: { + endpoint: { + actions: { + readPolicyManagement: { + executePackageAction: true, + }, + readTrustedApplications: { + executePackageAction: true, + }, + readEventFilters: { + executePackageAction: true, + }, + readHostIsolationExceptions: { + executePackageAction: true, + }, + readBlocklist: { + executePackageAction: true, + }, + }, + }, + }, + }, + }, + + // Package Policy Bulk GET API + [`post:${PACKAGE_POLICY_API_ROUTES.BULK_GET_PATTERN}`]: { + any: { + integrations: { + readIntegrationPolicies: true, + }, + packagePrivileges: { + endpoint: { + actions: { + readPolicyManagement: { + executePackageAction: true, + }, + readTrustedApplications: { + executePackageAction: true, + }, + readEventFilters: { + executePackageAction: true, + }, + readHostIsolationExceptions: { + executePackageAction: true, + }, + readBlocklist: { + executePackageAction: true, + }, + }, + }, + }, + }, + }, + + // Package Policy List API + [`get:${PACKAGE_POLICY_API_ROUTES.LIST_PATTERN}`]: { + any: { + integrations: { + readIntegrationPolicies: true, + }, + packagePrivileges: { + endpoint: { + actions: { + readPolicyManagement: { + executePackageAction: true, + }, + readTrustedApplications: { + executePackageAction: true, + }, + readEventFilters: { + executePackageAction: true, + }, + readHostIsolationExceptions: { + executePackageAction: true, + }, + readBlocklist: { + executePackageAction: true, + }, + }, + }, + }, + }, + }, +}); + +/** + * Retrieves the required fleet route authz + * in order to grant access to the given api route + * @param routeMethod + * @param routePath + */ +export const getRouteRequiredAuthz = ( + routeMethod: RouteMethod, + routePath: string +): FleetRouteRequiredAuthz | undefined => { + const key = `${routeMethod}:${routePath}`; + + if (typeof ROUTE_AUTHZ_REQUIREMENTS[key] !== 'undefined') { + return ROUTE_AUTHZ_REQUIREMENTS[key]; + } + + for (const k of Object.keys(ROUTE_AUTHZ_REQUIREMENTS)) { + if (pathMatchesPattern(k, key)) { + return ROUTE_AUTHZ_REQUIREMENTS[k]; + } + } +}; + +const pathMatchesPattern = (pathPattern: string, path: string): boolean => { + // No path params - pattern is single path + if (pathPattern === path) { + return true; + } + + // If pathPattern has params (`{value}`), then see if `path` matches it + if (/{.*?}/.test(pathPattern)) { + const pathParts = path.split(/\//); + const patternParts = pathPattern.split(/\//); + + if (pathParts.length !== patternParts.length) { + return false; + } + + return pathParts.every((part, index) => { + return part === patternParts[index] || /{.*?}/.test(patternParts[index]); + }); + } + + return false; +}; diff --git a/x-pack/plugins/fleet/server/services/security/security.test.ts b/x-pack/plugins/fleet/server/services/security/security.test.ts new file mode 100644 index 000000000000..f99a708504d6 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/security/security.test.ts @@ -0,0 +1,540 @@ +/* + * 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 { deepFreeze } from '@kbn/std'; + +import type { FleetAuthz } from '../../../common'; + +import { calculateRouteAuthz } from './security'; + +describe('When using calculateRouteAuthz()', () => { + const fleetAuthz = deepFreeze({ + fleet: { + all: false, + setup: false, + readEnrollmentTokens: false, + readAgentPolicies: false, + }, + integrations: { + readPackageInfo: false, + readInstalledPackages: false, + installPackages: false, + upgradePackages: false, + removePackages: false, + uploadPackages: false, + readPackageSettings: false, + writePackageSettings: false, + readIntegrationPolicies: false, + writeIntegrationPolicies: false, + }, + packagePrivileges: { + endpoint: { + actions: { + writeEndpointList: { + executePackageAction: false, + }, + readEndpointList: { + executePackageAction: false, + }, + writeTrustedApplications: { + executePackageAction: false, + }, + readTrustedApplications: { + executePackageAction: false, + }, + writeHostIsolationExceptions: { + executePackageAction: false, + }, + readHostIsolationExceptions: { + executePackageAction: false, + }, + writeBlocklist: { + executePackageAction: false, + }, + readBlocklist: { + executePackageAction: false, + }, + writeEventFilters: { + executePackageAction: false, + }, + readEventFilters: { + executePackageAction: false, + }, + writePolicyManagement: { + executePackageAction: false, + }, + readPolicyManagement: { + executePackageAction: false, + }, + writeActionsLogManagement: { + executePackageAction: false, + }, + readActionsLogManagement: { + executePackageAction: false, + }, + writeHostIsolation: { + executePackageAction: false, + }, + writeProcessOperations: { + executePackageAction: false, + }, + writeFileOperations: { + executePackageAction: false, + }, + }, + }, + + someOtherPackage: { + actions: { + readSomeThing: { + executePackageAction: false, + }, + }, + }, + }, + }); + + const getFleetAuthzMock = (authz: FleetAuthz = fleetAuthz) => authz; + + describe('with ANY object defined', () => { + it('should grant access if `any` are true', () => { + expect( + calculateRouteAuthz( + getFleetAuthzMock({ + ...fleetAuthz, + packagePrivileges: { + ...fleetAuthz.packagePrivileges, + endpoint: { + ...fleetAuthz.packagePrivileges.endpoint, + actions: { + ...fleetAuthz.packagePrivileges.endpoint.actions, + readPolicyManagement: { + executePackageAction: true, + }, + }, + }, + }, + }), + { + any: { + integrations: { + readPackageInfo: true, + removePackages: true, + }, + packagePrivileges: { + endpoint: { + actions: { + readPolicyManagement: { + executePackageAction: true, + }, + readBlocklist: { + executePackageAction: true, + }, + }, + }, + }, + }, + } + ) + ).toEqual({ + granted: true, + grantedByFleetPrivileges: false, + scopeDataToPackages: ['endpoint'], + }); + }); + + it('should deny access if `any` are false', () => { + expect( + calculateRouteAuthz(getFleetAuthzMock(), { + any: { + integrations: { + readPackageInfo: true, + removePackages: true, + }, + packagePrivileges: { + endpoint: { + actions: { + readPolicyManagement: { + executePackageAction: true, + }, + readBlocklist: { + executePackageAction: true, + }, + }, + }, + }, + }, + }) + ).toEqual({ + granted: false, + grantedByFleetPrivileges: false, + scopeDataToPackages: undefined, + }); + }); + }); + + describe('with ALL object defined', () => { + it('should grant access if `all` are true', () => { + expect( + calculateRouteAuthz( + getFleetAuthzMock({ + ...fleetAuthz, + integrations: { + ...fleetAuthz.integrations, + readPackageInfo: true, + removePackages: true, + }, + packagePrivileges: { + ...fleetAuthz.packagePrivileges, + endpoint: { + ...fleetAuthz.packagePrivileges.endpoint, + actions: { + ...fleetAuthz.packagePrivileges.endpoint.actions, + readPolicyManagement: { + executePackageAction: true, + }, + readBlocklist: { + executePackageAction: true, + }, + }, + }, + }, + }), + { + all: { + integrations: { + readPackageInfo: true, + removePackages: true, + }, + packagePrivileges: { + endpoint: { + actions: { + readPolicyManagement: { + executePackageAction: true, + }, + readBlocklist: { + executePackageAction: true, + }, + }, + }, + }, + }, + } + ) + ).toEqual({ granted: true, grantedByFleetPrivileges: true, scopeDataToPackages: undefined }); + }); + + it('should deny access if not `all` are true', () => { + expect( + calculateRouteAuthz( + getFleetAuthzMock({ + ...fleetAuthz, + packagePrivileges: { + ...fleetAuthz.packagePrivileges, + endpoint: { + ...fleetAuthz.packagePrivileges.endpoint, + actions: { + ...fleetAuthz.packagePrivileges.endpoint.actions, + readPolicyManagement: { + executePackageAction: true, + }, + }, + }, + }, + }), + { + all: { + integrations: { + readPackageInfo: true, + removePackages: true, + }, + packagePrivileges: { + endpoint: { + actions: { + readPolicyManagement: { + executePackageAction: true, + }, + readBlocklist: { + executePackageAction: true, + }, + }, + }, + }, + }, + } + ) + ).toEqual({ + granted: false, + grantedByFleetPrivileges: false, + scopeDataToPackages: undefined, + }); + }); + }); + + describe('with ALL and ANY', () => { + it('should grant access if `all` are true', () => { + expect( + calculateRouteAuthz( + getFleetAuthzMock({ + ...fleetAuthz, + integrations: { + ...fleetAuthz.integrations, + readPackageInfo: true, + removePackages: true, + }, + packagePrivileges: { + ...fleetAuthz.packagePrivileges, + endpoint: { + ...fleetAuthz.packagePrivileges.endpoint, + actions: { + ...fleetAuthz.packagePrivileges.endpoint.actions, + readPolicyManagement: { + executePackageAction: true, + }, + readBlocklist: { + executePackageAction: true, + }, + }, + }, + }, + }), + { + all: { + integrations: { + readPackageInfo: true, + removePackages: true, + }, + packagePrivileges: { + endpoint: { + actions: { + readPolicyManagement: { + executePackageAction: true, + }, + readBlocklist: { + executePackageAction: true, + }, + }, + }, + }, + }, + } + ) + ).toEqual({ granted: true, grantedByFleetPrivileges: true, scopeDataToPackages: undefined }); + }); + + it('should grant access if all OR any are true', () => { + expect( + calculateRouteAuthz( + getFleetAuthzMock({ + ...fleetAuthz, + integrations: { + ...fleetAuthz.integrations, + readPackageInfo: true, + removePackages: true, + }, + packagePrivileges: { + ...fleetAuthz.packagePrivileges, + endpoint: { + ...fleetAuthz.packagePrivileges.endpoint, + actions: { + ...fleetAuthz.packagePrivileges.endpoint.actions, + readPolicyManagement: { + executePackageAction: true, + }, + }, + }, + }, + }), + { + all: { + integrations: { + readPackageInfo: true, + removePackages: true, + }, + }, + any: { + packagePrivileges: { + endpoint: { + actions: { + readPolicyManagement: { + executePackageAction: true, + }, + readBlocklist: { + executePackageAction: true, + }, + }, + }, + }, + }, + } + ) + ).toEqual({ granted: true, grantedByFleetPrivileges: true, scopeDataToPackages: undefined }); + }); + + it('should grant access if `all` are not true but `any` are true ', () => { + expect( + calculateRouteAuthz( + getFleetAuthzMock({ + ...fleetAuthz, + integrations: { + ...fleetAuthz.integrations, + readPackageInfo: true, + }, + packagePrivileges: { + ...fleetAuthz.packagePrivileges, + endpoint: { + ...fleetAuthz.packagePrivileges.endpoint, + actions: { + ...fleetAuthz.packagePrivileges.endpoint.actions, + readPolicyManagement: { + executePackageAction: true, + }, + }, + }, + + someOtherPackage: { + actions: { + readSomeThing: { + executePackageAction: true, + }, + }, + }, + }, + }), + { + all: { + integrations: { + readPackageInfo: true, + removePackages: true, + }, + }, + any: { + packagePrivileges: { + endpoint: { + actions: { + readPolicyManagement: { + executePackageAction: true, + }, + readBlocklist: { + executePackageAction: true, + }, + }, + }, + someOtherPackage: { + actions: { + readSomeThing: { + executePackageAction: true, + }, + }, + }, + }, + }, + } + ) + ).toEqual({ + granted: true, + grantedByFleetPrivileges: false, + scopeDataToPackages: ['endpoint', 'someOtherPackage'], + }); + }); + + it('should grant access if `all` are true but `any` are not true ', () => { + expect( + calculateRouteAuthz( + getFleetAuthzMock({ + ...fleetAuthz, + integrations: { + ...fleetAuthz.integrations, + readPackageInfo: true, + removePackages: true, + }, + }), + { + all: { + integrations: { + readPackageInfo: true, + removePackages: true, + }, + }, + any: { + packagePrivileges: { + endpoint: { + actions: { + readPolicyManagement: { + executePackageAction: true, + }, + readBlocklist: { + executePackageAction: true, + }, + }, + }, + }, + }, + } + ) + ).toEqual({ granted: true, grantedByFleetPrivileges: true, scopeDataToPackages: undefined }); + }); + }); + + describe('and access is granted based on package privileges', () => { + it('should exclude package names for which there is no access allowed', () => { + expect( + calculateRouteAuthz( + getFleetAuthzMock({ + ...fleetAuthz, + packagePrivileges: { + ...fleetAuthz.packagePrivileges, + endpoint: { + ...fleetAuthz.packagePrivileges.endpoint, + actions: { + ...fleetAuthz.packagePrivileges.endpoint.actions, + readPolicyManagement: { + executePackageAction: true, + }, + }, + }, + }, + }), + { + all: { + integrations: { + readPackageInfo: true, + removePackages: true, + }, + }, + any: { + packagePrivileges: { + endpoint: { + actions: { + readPolicyManagement: { + executePackageAction: true, + }, + readBlocklist: { + executePackageAction: true, + }, + }, + }, + // This package Authz is not allowed, so it should not be listed in `scopeDataToPackages` + someOtherPackage: { + actions: { + readSomeThing: { + executePackageAction: true, + }, + }, + }, + }, + }, + } + ) + ).toEqual({ + granted: true, + grantedByFleetPrivileges: false, + scopeDataToPackages: ['endpoint'], + }); + }); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/security/security.ts b/x-pack/plugins/fleet/server/services/security/security.ts new file mode 100644 index 000000000000..c5dabecc8090 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/security/security.ts @@ -0,0 +1,252 @@ +/* + * 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 { pick } from 'lodash'; + +import type { KibanaRequest } from '@kbn/core/server'; +import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server'; + +import type { FleetAuthz } from '../../../common'; +import { INTEGRATIONS_PLUGIN_ID } from '../../../common'; +import { + calculateAuthz, + calculatePackagePrivilegesFromKibanaPrivileges, +} from '../../../common/authz'; + +import { appContextService } from '..'; +import { ENDPOINT_PRIVILEGES, PLUGIN_ID } from '../../constants'; + +import type { + FleetAuthzRequirements, + FleetRouteRequiredAuthz, + FleetAuthzRouteConfig, +} from './types'; + +export function checkSecurityEnabled() { + return appContextService.getSecurityLicense().isEnabled(); +} + +export function checkSuperuser(req: KibanaRequest) { + if (!checkSecurityEnabled()) { + return false; + } + + const security = appContextService.getSecurity(); + const user = security.authc.getCurrentUser(req); + if (!user) { + return false; + } + + const userRoles = user.roles || []; + if (!userRoles.includes('superuser')) { + return false; + } + + return true; +} + +function getAuthorizationFromPrivileges( + kibanaPrivileges: Array<{ + resource?: string; + privilege: string; + authorized: boolean; + }>, + searchPrivilege: string +) { + const privilege = kibanaPrivileges.find((p) => p.privilege.includes(searchPrivilege)); + return privilege ? privilege.authorized : false; +} + +export async function getAuthzFromRequest(req: KibanaRequest): Promise { + const security = appContextService.getSecurity(); + + if (security.authz.mode.useRbacForRequest(req)) { + const checkPrivileges = security.authz.checkPrivilegesDynamicallyWithRequest(req); + const endpointPrivileges = ENDPOINT_PRIVILEGES.map((privilege) => + security.authz.actions.api.get(`${DEFAULT_APP_CATEGORIES.security.id}-${privilege}`) + ); + const { privileges } = await checkPrivileges({ + kibana: [ + security.authz.actions.api.get(`${PLUGIN_ID}-all`), + security.authz.actions.api.get(`${PLUGIN_ID}-setup`), + security.authz.actions.api.get(`${INTEGRATIONS_PLUGIN_ID}-all`), + security.authz.actions.api.get(`${INTEGRATIONS_PLUGIN_ID}-read`), + ...endpointPrivileges, + ], + }); + const fleetAllAuth = getAuthorizationFromPrivileges(privileges.kibana, `${PLUGIN_ID}-all`); + const intAllAuth = getAuthorizationFromPrivileges( + privileges.kibana, + `${INTEGRATIONS_PLUGIN_ID}-all` + ); + const intReadAuth = getAuthorizationFromPrivileges( + privileges.kibana, + `${INTEGRATIONS_PLUGIN_ID}-read` + ); + const fleetSetupAuth = getAuthorizationFromPrivileges(privileges.kibana, 'fleet-setup'); + + return { + ...calculateAuthz({ + fleet: { all: fleetAllAuth, setup: fleetSetupAuth }, + integrations: { + all: intAllAuth, + read: intReadAuth, + }, + isSuperuser: checkSuperuser(req), + }), + packagePrivileges: calculatePackagePrivilegesFromKibanaPrivileges(privileges.kibana), + }; + } + + return calculateAuthz({ + fleet: { all: false, setup: false }, + integrations: { + all: false, + read: false, + }, + isSuperuser: false, + }); +} + +interface RouteAuthz { + /** Is route access granted (based on authz) */ + granted: boolean; + + /** Was authorization to the api a result of Fleet (and Integrations) Privileges (as oposed to Package privileges) */ + grantedByFleetPrivileges: boolean; + + /** + * Set when `grantedByFleetPrivileges` is `false` and `granted` is true, which indicate access was granted + * via a Package Privileges. Array will hold the list of Package names that are allowed + */ + scopeDataToPackages: string[] | undefined; +} + +/** + * Calculates Authz information for a Route, including: + * 1. Is access granted + * 2. was access granted based on Fleet and/or Integration privileges, and + * 3. a list of package names for which access was granted (only set if access was granted by package privileges) + * + * @param fleetAuthz + * @param requiredAuthz + */ +export const calculateRouteAuthz = ( + fleetAuthz: FleetAuthz, + requiredAuthz: FleetRouteRequiredAuthz | undefined +): RouteAuthz => { + const response: RouteAuthz = { + granted: false, + grantedByFleetPrivileges: false, + scopeDataToPackages: undefined, + }; + const fleetAuthzFlatten = flatten(fleetAuthz); + + const isPrivilegeGranted = (flattenPrivilegeKey: string): boolean => + fleetAuthzFlatten[flattenPrivilegeKey] === true; + + if (typeof requiredAuthz === 'undefined') { + return response; + } + + if (requiredAuthz.all) { + response.granted = Object.keys(flatten(requiredAuthz.all)).every(isPrivilegeGranted); + + if (response.granted) { + if (requiredAuthz.all.fleet || requiredAuthz.all.integrations) { + response.grantedByFleetPrivileges = true; + } + + return response; + } + } + + if (requiredAuthz.any) { + response.granted = Object.keys(flatten(requiredAuthz.any)).some(isPrivilegeGranted); + + if (response.granted) { + // Figure out if authz was granted via Fleet privileges + if (requiredAuthz.any.fleet || requiredAuthz.any.integrations) { + const fleetAnyPrivileges = pick(requiredAuthz.any, ['fleet', 'integrations']); + + response.grantedByFleetPrivileges = Object.keys(flatten(fleetAnyPrivileges)).some( + isPrivilegeGranted + ); + } + + // If access was NOT granted via Fleet Authz, then retrieve a list of Package names that were + // granted access to their respective data. + if (!response.grantedByFleetPrivileges && requiredAuthz.any.packagePrivileges) { + for (const [packageName, packageRequiredAuthz] of Object.entries( + requiredAuthz.any.packagePrivileges + )) { + const packageRequiredAuthzKeys = Object.keys( + flatten({ packagePrivileges: { [packageName]: packageRequiredAuthz } }) + ); + + if (packageRequiredAuthzKeys.some(isPrivilegeGranted)) { + if (!response.scopeDataToPackages) { + response.scopeDataToPackages = []; + } + + response.scopeDataToPackages.push(packageName); + } + } + } + + return response; + } + } + + return response; +}; + +/** + * Utility to flatten an object's key all the way down to the last value. + * @param source + */ +function flatten(source: FleetAuthzRequirements | FleetAuthz): Record { + const response: Record = {}; + const processKeys = (prefix: string, value: unknown) => { + if (typeof value === 'object' && value !== null) { + const objectKeys = Object.keys(value); + + for (const key of objectKeys) { + processKeys(`${prefix}${prefix ? '.' : ''}${key}`, (value as Record)[key]); + } + } else if (Array.isArray(value)) { + value.forEach((subValue, key) => { + processKeys(`${prefix}${prefix ? '.' : ''}${key}`, subValue); + }); + } else { + response[prefix] = value as boolean; + } + }; + + processKeys('', source); + + return response; +} + +/** + * Utility to determine if a user has the required Fleet Authz based on user privileges + * and route required authz structure. + * @param authz + * @param fleetRequiredAuthz + * @returns boolean + */ +export const doesNotHaveRequiredFleetAuthz = ( + authz: FleetAuthz, + fleetRequiredAuthz: FleetAuthzRouteConfig['fleetAuthz'] +): boolean => { + return ( + !!fleetRequiredAuthz && + ((typeof fleetRequiredAuthz === 'function' && !fleetRequiredAuthz(authz)) || + (typeof fleetRequiredAuthz !== 'function' && + !calculateRouteAuthz(authz, { all: fleetRequiredAuthz }).granted)) + ); +}; diff --git a/x-pack/plugins/fleet/server/services/security/types.ts b/x-pack/plugins/fleet/server/services/security/types.ts new file mode 100644 index 000000000000..8559ee57b35e --- /dev/null +++ b/x-pack/plugins/fleet/server/services/security/types.ts @@ -0,0 +1,62 @@ +/* + * 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 { RouteConfig, RouteMethod } from '@kbn/core-http-server'; +import type { RequestHandlerContext } from '@kbn/core-http-request-handler-context-server'; +import type { IRouter, RequestHandler } from '@kbn/core/server'; + +import type { FleetRequestHandlerContext } from '../..'; + +import type { FleetAuthz } from '../../../common'; + +/** The values allowed for the `fleetAuthz` property of the Fleet Router registration interface. */ +type FleetAuthzRouterConfigParam = FleetAuthzRequirements | ((userAuthz: FleetAuthz) => boolean); + +type FleetAuthzRouteRegistrar< + Method extends RouteMethod, + Context extends RequestHandlerContext = RequestHandlerContext +> = ( + route: FleetRouteConfig, + handler: RequestHandler +) => void; + +export interface FleetAuthzRouteConfig< + T extends FleetAuthzRouterConfigParam = FleetAuthzRouterConfigParam +> { + fleetAuthz?: T; +} + +export type FleetRouteConfig = RouteConfig & + FleetAuthzRouteConfig; + +// Fleet router that allow to add required access when registering route +export interface FleetAuthzRouter< + TContext extends FleetRequestHandlerContext = FleetRequestHandlerContext +> extends IRouter { + get: FleetAuthzRouteRegistrar<'get', TContext>; + delete: FleetAuthzRouteRegistrar<'delete', TContext>; + post: FleetAuthzRouteRegistrar<'post', TContext>; + put: FleetAuthzRouteRegistrar<'put', TContext>; + patch: FleetAuthzRouteRegistrar<'patch', TContext>; +} + +type DeepPartialTruthy = { + [P in keyof T]?: T[P] extends boolean ? true : DeepPartialTruthy; +}; + +/** + * The set of authz properties required to be granted access to an API route + */ +export type FleetAuthzRequirements = DeepPartialTruthy; + +/** + * Interface used for registering and calculating authorization for a Fleet API routes + */ +export type FleetRouteRequiredAuthz = Partial<{ + any: FleetAuthzRequirements; + all: FleetAuthzRequirements; +}>; diff --git a/x-pack/plugins/fleet/server/types/request_context.ts b/x-pack/plugins/fleet/server/types/request_context.ts index f803d1c0cd6b..bc0e1c8886bf 100644 --- a/x-pack/plugins/fleet/server/types/request_context.ts +++ b/x-pack/plugins/fleet/server/types/request_context.ts @@ -32,14 +32,18 @@ export type FleetRequestHandlerContext = CustomRequestHandlerContext<{ asCurrentUser: PackagePolicyClient; asInternalUser: PackagePolicyClient; }; - epm: { - /** - * Saved Objects client configured to use kibana_system privileges instead of end-user privileges. Should only be - * used by routes that have additional privilege checks for authorization (such as requiring superuser). - */ - readonly internalSoClient: SavedObjectsClientContract; - }; + /** + * Saved Objects client configured to use kibana_system privileges instead of end-user privileges. Should only be + * used by routes that have additional privilege checks for authorization (such as requiring superuser). + */ + readonly internalSoClient: SavedObjectsClientContract; + spaceId: string; + /** + * If data is to be limited to the list of integration package names. This will be set when + * authz to the API was granted only based on Package Privileges. + */ + limitedToPackages: string[] | undefined; }; }>; diff --git a/x-pack/test/fleet_api_integration/apis/epm/bulk_upgrade.ts b/x-pack/test/fleet_api_integration/apis/epm/bulk_upgrade.ts index fe875fe4e256..ad9c1eec519c 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/bulk_upgrade.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/bulk_upgrade.ts @@ -51,6 +51,7 @@ export default function (providerContext: FtrProviderContext) { .post(`/api/fleet/epm/packages/_bulk`) .auth(testUsers.fleet_all_int_read.username, testUsers.fleet_all_int_read.password) .set('kbn-xsrf', 'xxxx') + .send({ packages: ['multiple_versions', 'overrides'] }) .expect(403); }); it('should return 403 if user without fleet access requests upgrade', async function () { @@ -58,6 +59,7 @@ export default function (providerContext: FtrProviderContext) { .post(`/api/fleet/epm/packages/_bulk`) .auth(testUsers.integr_all_only.username, testUsers.integr_all_only.password) .set('kbn-xsrf', 'xxxx') + .send({ packages: ['multiple_versions', 'overrides'] }) .expect(403); }); it('should return 200 and an array for upgrading a package', async function () {