Allow elastic/fleet-server to call appropriate Fleet APIs (#113932)

This commit is contained in:
Josh Dover 2021-10-19 12:14:57 +02:00 committed by GitHub
parent f9afe67f1e
commit 5974fcfdb5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 653 additions and 133 deletions

View file

@ -23,7 +23,17 @@ import type { FleetAppContext } from '../plugin';
// Export all mocks from artifacts
export * from '../services/artifacts/mocks';
export const createAppContextStartContractMock = (): FleetAppContext => {
export interface MockedFleetAppContext extends FleetAppContext {
elasticsearch: ReturnType<typeof elasticsearchServiceMock.createStart>;
data: ReturnType<typeof dataPluginMock.createStartContract>;
encryptedSavedObjectsStart?: ReturnType<typeof encryptedSavedObjectsMock.createStart>;
savedObjects: ReturnType<typeof savedObjectsServiceMock.createStartContract>;
securitySetup?: ReturnType<typeof securityMock.createSetup>;
securityStart?: ReturnType<typeof securityMock.createStart>;
logger: ReturnType<ReturnType<typeof loggingSystemMock.create>['get']>;
}
export const createAppContextStartContractMock = (): MockedFleetAppContext => {
const config = {
agents: { enabled: true, elasticsearch: {} },
enabled: true,

View file

@ -80,9 +80,10 @@ import {
} from './services/agents';
import { registerFleetUsageCollector } from './collectors/register';
import { getInstallation, ensureInstalledPackage } from './services/epm/packages';
import { makeRouterEnforcingSuperuser } from './routes/security';
import { RouterWrappers } from './routes/security';
import { startFleetServerSetup } from './services/fleet_server';
import { FleetArtifactsClient } from './services/artifacts';
import type { FleetRouter } from './types/request_context';
export interface FleetSetupDeps {
licensing: LicensingPluginSetup;
@ -206,6 +207,24 @@ export class FleetPlugin
category: DEFAULT_APP_CATEGORIES.management,
app: [PLUGIN_ID, INTEGRATIONS_PLUGIN_ID, 'kibana'],
catalogue: ['fleet'],
reserved: {
description:
'Privilege to setup Fleet packages and configured policies. Intended for use by the elastic/fleet-server service account only.',
privileges: [
{
id: 'fleet-setup',
privilege: {
excludeFromBasePrivileges: true,
api: ['fleet-setup'],
savedObject: {
all: [],
read: [],
},
ui: [],
},
},
],
},
privileges: {
all: {
api: [`${PLUGIN_ID}-read`, `${PLUGIN_ID}-all`],
@ -245,7 +264,7 @@ export class FleetPlugin
})
);
const router = core.http.createRouter<FleetRequestHandlerContext>();
const router: FleetRouter = core.http.createRouter<FleetRequestHandlerContext>();
// Register usage collection
registerFleetUsageCollector(core, config, deps.usageCollection);
@ -254,24 +273,34 @@ export class FleetPlugin
registerAppRoutes(router);
// Allow read-only users access to endpoints necessary for Integrations UI
// Only some endpoints require superuser so we pass a raw IRouter here
registerEPMRoutes(router);
// For all the routes we enforce the user to have role superuser
const routerSuperuserOnly = makeRouterEnforcingSuperuser(router);
const superuserRouter = RouterWrappers.require.superuser(router);
const fleetSetupRouter = RouterWrappers.require.fleetSetupPrivilege(router);
// Some EPM routes use regular rbac to support integrations app
registerEPMRoutes({ rbac: router, superuser: superuserRouter });
// Register rest of routes only if security is enabled
if (deps.security) {
registerSetupRoutes(routerSuperuserOnly, config);
registerAgentPolicyRoutes(routerSuperuserOnly);
registerPackagePolicyRoutes(routerSuperuserOnly);
registerOutputRoutes(routerSuperuserOnly);
registerSettingsRoutes(routerSuperuserOnly);
registerDataStreamRoutes(routerSuperuserOnly);
registerPreconfigurationRoutes(routerSuperuserOnly);
registerSetupRoutes(fleetSetupRouter, config);
registerAgentPolicyRoutes({
fleetSetup: fleetSetupRouter,
superuser: superuserRouter,
});
registerPackagePolicyRoutes(superuserRouter);
registerOutputRoutes(superuserRouter);
registerSettingsRoutes(superuserRouter);
registerDataStreamRoutes(superuserRouter);
registerPreconfigurationRoutes(superuserRouter);
// Conditional config routes
if (config.agents.enabled) {
registerAgentAPIRoutes(routerSuperuserOnly, config);
registerEnrollmentApiKeyRoutes(routerSuperuserOnly);
registerAgentAPIRoutes(superuserRouter, config);
registerEnrollmentApiKeyRoutes({
fleetSetup: fleetSetupRouter,
superuser: superuserRouter,
});
}
}
}

View file

@ -5,8 +5,6 @@
* 2.0.
*/
import type { IRouter } from 'src/core/server';
import { PLUGIN_ID, AGENT_POLICY_API_ROUTES } from '../../constants';
import {
GetAgentPoliciesRequestSchema,
@ -17,6 +15,7 @@ import {
DeleteAgentPolicyRequestSchema,
GetFullAgentPolicyRequestSchema,
} from '../../types';
import type { FleetRouter } from '../../types/request_context';
import {
getAgentPoliciesHandler,
@ -29,19 +28,21 @@ import {
downloadFullAgentPolicy,
} from './handlers';
export const registerRoutes = (router: IRouter) => {
// List
router.get(
export const registerRoutes = (routers: { superuser: FleetRouter; fleetSetup: FleetRouter }) => {
// List - Fleet Server needs access to run setup
routers.fleetSetup.get(
{
path: AGENT_POLICY_API_ROUTES.LIST_PATTERN,
validate: GetAgentPoliciesRequestSchema,
options: { tags: [`access:${PLUGIN_ID}-read`] },
// Disable this tag and the automatic RBAC support until elastic/fleet-server access is removed in 8.0
// Required to allow elastic/fleet-server to access this API.
// options: { tags: [`access:${PLUGIN_ID}-read`] },
},
getAgentPoliciesHandler
);
// Get one
router.get(
routers.superuser.get(
{
path: AGENT_POLICY_API_ROUTES.INFO_PATTERN,
validate: GetOneAgentPolicyRequestSchema,
@ -51,7 +52,7 @@ export const registerRoutes = (router: IRouter) => {
);
// Create
router.post(
routers.superuser.post(
{
path: AGENT_POLICY_API_ROUTES.CREATE_PATTERN,
validate: CreateAgentPolicyRequestSchema,
@ -61,7 +62,7 @@ export const registerRoutes = (router: IRouter) => {
);
// Update
router.put(
routers.superuser.put(
{
path: AGENT_POLICY_API_ROUTES.UPDATE_PATTERN,
validate: UpdateAgentPolicyRequestSchema,
@ -71,7 +72,7 @@ export const registerRoutes = (router: IRouter) => {
);
// Copy
router.post(
routers.superuser.post(
{
path: AGENT_POLICY_API_ROUTES.COPY_PATTERN,
validate: CopyAgentPolicyRequestSchema,
@ -81,7 +82,7 @@ export const registerRoutes = (router: IRouter) => {
);
// Delete
router.post(
routers.superuser.post(
{
path: AGENT_POLICY_API_ROUTES.DELETE_PATTERN,
validate: DeleteAgentPolicyRequestSchema,
@ -91,7 +92,7 @@ export const registerRoutes = (router: IRouter) => {
);
// Get one full agent policy
router.get(
routers.superuser.get(
{
path: AGENT_POLICY_API_ROUTES.FULL_INFO_PATTERN,
validate: GetFullAgentPolicyRequestSchema,
@ -101,7 +102,7 @@ export const registerRoutes = (router: IRouter) => {
);
// Download one full agent policy
router.get(
routers.superuser.get(
{
path: AGENT_POLICY_API_ROUTES.FULL_INFO_DOWNLOAD_PATTERN,
validate: GetFullAgentPolicyRequestSchema,

View file

@ -27,7 +27,8 @@ export const getEnrollmentApiKeysHandler: RequestHandler<
undefined,
TypeOf<typeof GetEnrollmentAPIKeysRequestSchema.query>
> = async (context, request, response) => {
const esClient = context.core.elasticsearch.client.asCurrentUser;
// Use kibana_system and depend on authz checks on HTTP layer to prevent abuse
const esClient = context.core.elasticsearch.client.asInternalUser;
try {
const { items, total, page, perPage } = await APIKeyService.listEnrollmentApiKeys(esClient, {
@ -87,7 +88,8 @@ export const deleteEnrollmentApiKeyHandler: RequestHandler<
export const getOneEnrollmentApiKeyHandler: RequestHandler<
TypeOf<typeof GetOneEnrollmentAPIKeyRequestSchema.params>
> = async (context, request, response) => {
const esClient = context.core.elasticsearch.client.asCurrentUser;
// Use kibana_system and depend on authz checks on HTTP layer to prevent abuse
const esClient = context.core.elasticsearch.client.asInternalUser;
try {
const apiKey = await APIKeyService.getEnrollmentAPIKey(esClient, request.params.keyId);
const body: GetOneEnrollmentAPIKeyResponse = { item: apiKey };

View file

@ -5,8 +5,6 @@
* 2.0.
*/
import type { IRouter } from 'src/core/server';
import { PLUGIN_ID, ENROLLMENT_API_KEY_ROUTES } from '../../constants';
import {
GetEnrollmentAPIKeysRequestSchema,
@ -14,6 +12,7 @@ import {
DeleteEnrollmentAPIKeyRequestSchema,
PostEnrollmentAPIKeyRequestSchema,
} from '../../types';
import type { FleetRouter } from '../../types/request_context';
import {
getEnrollmentApiKeysHandler,
@ -22,17 +21,19 @@ import {
postEnrollmentApiKeyHandler,
} from './handler';
export const registerRoutes = (router: IRouter) => {
router.get(
export const registerRoutes = (routers: { superuser: FleetRouter; fleetSetup: FleetRouter }) => {
routers.fleetSetup.get(
{
path: ENROLLMENT_API_KEY_ROUTES.INFO_PATTERN,
validate: GetOneEnrollmentAPIKeyRequestSchema,
options: { tags: [`access:${PLUGIN_ID}-read`] },
// Disable this tag and the automatic RBAC support until elastic/fleet-server access is removed in 8.0
// Required to allow elastic/fleet-server to access this API.
// options: { tags: [`access:${PLUGIN_ID}-read`] },
},
getOneEnrollmentApiKeyHandler
);
router.delete(
routers.superuser.delete(
{
path: ENROLLMENT_API_KEY_ROUTES.DELETE_PATTERN,
validate: DeleteEnrollmentAPIKeyRequestSchema,
@ -41,16 +42,18 @@ export const registerRoutes = (router: IRouter) => {
deleteEnrollmentApiKeyHandler
);
router.get(
routers.fleetSetup.get(
{
path: ENROLLMENT_API_KEY_ROUTES.LIST_PATTERN,
validate: GetEnrollmentAPIKeysRequestSchema,
options: { tags: [`access:${PLUGIN_ID}-read`] },
// Disable this tag and the automatic RBAC support until elastic/fleet-server access is removed in 8.0
// Required to allow elastic/fleet-server to access this API.
// options: { tags: [`access:${PLUGIN_ID}-read`] },
},
getEnrollmentApiKeysHandler
);
router.post(
routers.superuser.post(
{
path: ENROLLMENT_API_KEY_ROUTES.CREATE_PATTERN,
validate: PostEnrollmentAPIKeyRequestSchema,

View file

@ -5,10 +5,7 @@
* 2.0.
*/
import type { IRouter } from 'src/core/server';
import { PLUGIN_ID, EPM_API_ROUTES } from '../../constants';
import type { FleetRequestHandlerContext } from '../../types';
import {
GetCategoriesRequestSchema,
GetPackagesRequestSchema,
@ -21,7 +18,7 @@ import {
GetStatsRequestSchema,
UpdatePackageRequestSchema,
} from '../../types';
import { enforceSuperUser } from '../security';
import type { FleetRouter } from '../../types/request_context';
import {
getCategoriesHandler,
@ -39,8 +36,8 @@ import {
const MAX_FILE_SIZE_BYTES = 104857600; // 100MB
export const registerRoutes = (router: IRouter<FleetRequestHandlerContext>) => {
router.get(
export const registerRoutes = (routers: { rbac: FleetRouter; superuser: FleetRouter }) => {
routers.rbac.get(
{
path: EPM_API_ROUTES.CATEGORIES_PATTERN,
validate: GetCategoriesRequestSchema,
@ -49,7 +46,7 @@ export const registerRoutes = (router: IRouter<FleetRequestHandlerContext>) => {
getCategoriesHandler
);
router.get(
routers.rbac.get(
{
path: EPM_API_ROUTES.LIST_PATTERN,
validate: GetPackagesRequestSchema,
@ -58,7 +55,7 @@ export const registerRoutes = (router: IRouter<FleetRequestHandlerContext>) => {
getListHandler
);
router.get(
routers.rbac.get(
{
path: EPM_API_ROUTES.LIMITED_LIST_PATTERN,
validate: false,
@ -67,7 +64,7 @@ export const registerRoutes = (router: IRouter<FleetRequestHandlerContext>) => {
getLimitedListHandler
);
router.get(
routers.rbac.get(
{
path: EPM_API_ROUTES.STATS_PATTERN,
validate: GetStatsRequestSchema,
@ -76,7 +73,7 @@ export const registerRoutes = (router: IRouter<FleetRequestHandlerContext>) => {
getStatsHandler
);
router.get(
routers.rbac.get(
{
path: EPM_API_ROUTES.FILEPATH_PATTERN,
validate: GetFileRequestSchema,
@ -85,7 +82,7 @@ export const registerRoutes = (router: IRouter<FleetRequestHandlerContext>) => {
getFileHandler
);
router.get(
routers.rbac.get(
{
path: EPM_API_ROUTES.INFO_PATTERN,
validate: GetInfoRequestSchema,
@ -94,34 +91,34 @@ export const registerRoutes = (router: IRouter<FleetRequestHandlerContext>) => {
getInfoHandler
);
router.put(
routers.superuser.put(
{
path: EPM_API_ROUTES.INFO_PATTERN,
validate: UpdatePackageRequestSchema,
options: { tags: [`access:${PLUGIN_ID}-all`] },
},
enforceSuperUser(updatePackageHandler)
updatePackageHandler
);
router.post(
routers.superuser.post(
{
path: EPM_API_ROUTES.INSTALL_FROM_REGISTRY_PATTERN,
validate: InstallPackageFromRegistryRequestSchema,
options: { tags: [`access:${PLUGIN_ID}-all`] },
},
enforceSuperUser(installPackageFromRegistryHandler)
installPackageFromRegistryHandler
);
router.post(
routers.superuser.post(
{
path: EPM_API_ROUTES.BULK_INSTALL_PATTERN,
validate: BulkUpgradePackagesFromRegistryRequestSchema,
options: { tags: [`access:${PLUGIN_ID}-all`] },
},
enforceSuperUser(bulkInstallPackagesFromRegistryHandler)
bulkInstallPackagesFromRegistryHandler
);
router.post(
routers.superuser.post(
{
path: EPM_API_ROUTES.INSTALL_BY_UPLOAD_PATTERN,
validate: InstallPackageByUploadRequestSchema,
@ -134,15 +131,15 @@ export const registerRoutes = (router: IRouter<FleetRequestHandlerContext>) => {
},
},
},
enforceSuperUser(installPackageByUploadHandler)
installPackageByUploadHandler
);
router.delete(
routers.superuser.delete(
{
path: EPM_API_ROUTES.DELETE_PATTERN,
validate: DeletePackageRequestSchema,
options: { tags: [`access:${PLUGIN_ID}-all`] },
},
enforceSuperUser(deletePackageHandler)
deletePackageHandler
);
};

View file

@ -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 type { IRouter, RequestHandler, RouteConfig } from '../../../../../src/core/server';
import { coreMock } from '../../../../../src/core/server/mocks';
import type { AuthenticatedUser } from '../../../security/server';
import type { CheckPrivilegesDynamically } from '../../../security/server/authorization/check_privileges_dynamically';
import { createAppContextStartContractMock } from '../mocks';
import { appContextService } from '../services';
import type { RouterWrapper } from './security';
import { RouterWrappers } from './security';
describe('RouterWrappers', () => {
const runTest = async ({
wrapper,
security: {
roles = [],
pluginEnabled = true,
licenseEnabled = true,
checkPrivilegesDynamically,
} = {},
}: {
wrapper: RouterWrapper;
security?: {
roles?: string[];
pluginEnabled?: boolean;
licenseEnabled?: boolean;
checkPrivilegesDynamically?: CheckPrivilegesDynamically;
};
}) => {
const fakeRouter = {
get: jest.fn(),
} as unknown as jest.Mocked<IRouter>;
const fakeHandler: RequestHandler = jest.fn((ctx, req, res) => res.ok());
const mockContext = createAppContextStartContractMock();
// @ts-expect-error type doesn't properly respect deeply mocked keys
mockContext.securityStart?.authz.actions.api.get.mockImplementation((priv) => `api:${priv}`);
if (!pluginEnabled) {
mockContext.securitySetup = undefined;
mockContext.securityStart = undefined;
} else {
mockContext.securityStart?.authc.getCurrentUser.mockReturnValue({
username: 'foo',
roles,
} as unknown as AuthenticatedUser);
mockContext.securitySetup?.license.isEnabled.mockReturnValue(licenseEnabled);
if (licenseEnabled) {
mockContext.securityStart?.authz.mode.useRbacForRequest.mockReturnValue(true);
}
if (checkPrivilegesDynamically) {
mockContext.securityStart?.authz.checkPrivilegesDynamicallyWithRequest.mockReturnValue(
checkPrivilegesDynamically
);
}
}
appContextService.start(mockContext);
const wrappedRouter = wrapper(fakeRouter);
wrappedRouter.get({} as RouteConfig<any, any, any, any>, fakeHandler);
const wrappedHandler = fakeRouter.get.mock.calls[0][1];
const resFactory = { forbidden: jest.fn(() => 'forbidden'), ok: jest.fn(() => 'ok') };
const res = await wrappedHandler(
{ core: coreMock.createRequestHandlerContext() },
{} as any,
resFactory as any
);
return res as unknown as 'forbidden' | 'ok';
};
describe('require.superuser', () => {
it('allow users with the superuser role', async () => {
expect(
await runTest({
wrapper: RouterWrappers.require.superuser,
security: { roles: ['superuser'] },
})
).toEqual('ok');
});
it('does not allow users without the superuser role', async () => {
expect(
await runTest({
wrapper: RouterWrappers.require.superuser,
security: { roles: ['foo'] },
})
).toEqual('forbidden');
});
it('does not allow security plugin to be disabled', async () => {
expect(
await runTest({
wrapper: RouterWrappers.require.superuser,
security: { pluginEnabled: false },
})
).toEqual('forbidden');
});
it('does not allow security license to be disabled', async () => {
expect(
await runTest({
wrapper: RouterWrappers.require.superuser,
security: { licenseEnabled: false },
})
).toEqual('forbidden');
});
});
describe('require.fleetSetupPrivilege', () => {
const mockCheckPrivileges: jest.Mock<
ReturnType<CheckPrivilegesDynamically>,
Parameters<CheckPrivilegesDynamically>
> = jest.fn().mockResolvedValue({ hasAllRequested: true });
it('executes custom authz check', async () => {
await runTest({
wrapper: RouterWrappers.require.fleetSetupPrivilege,
security: { checkPrivilegesDynamically: mockCheckPrivileges },
});
expect(mockCheckPrivileges).toHaveBeenCalledWith(
{ kibana: ['api:fleet-setup'] },
{
requireLoginAction: false,
}
);
});
it('allow users with required privileges', async () => {
expect(
await runTest({
wrapper: RouterWrappers.require.fleetSetupPrivilege,
security: { checkPrivilegesDynamically: mockCheckPrivileges },
})
).toEqual('ok');
});
it('does not allow users without required privileges', async () => {
mockCheckPrivileges.mockResolvedValueOnce({ hasAllRequested: false } as any);
expect(
await runTest({
wrapper: RouterWrappers.require.fleetSetupPrivilege,
security: { checkPrivilegesDynamically: mockCheckPrivileges },
})
).toEqual('forbidden');
});
it('does not allow security plugin to be disabled', async () => {
expect(
await runTest({
wrapper: RouterWrappers.require.fleetSetupPrivilege,
security: { pluginEnabled: false },
})
).toEqual('forbidden');
});
it('does not allow security license to be disabled', async () => {
expect(
await runTest({
wrapper: RouterWrappers.require.fleetSetupPrivilege,
security: { licenseEnabled: false },
})
).toEqual('forbidden');
});
});
});

View file

@ -5,56 +5,137 @@
* 2.0.
*/
import type { IRouter, RequestHandler, RequestHandlerContext } from 'src/core/server';
import type {
IRouter,
KibanaRequest,
RequestHandler,
RequestHandlerContext,
} from 'src/core/server';
import { appContextService } from '../services';
export function enforceSuperUser<T1, T2, T3, TContext extends RequestHandlerContext>(
const SUPERUSER_AUTHZ_MESSAGE =
'Access to Fleet API requires the superuser role and for stack security features to be enabled.';
function checkSecurityEnabled() {
return appContextService.hasSecurity() && appContextService.getSecurityLicense().isEnabled();
}
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 enforceSuperuser<T1, T2, T3, TContext extends RequestHandlerContext>(
handler: RequestHandler<T1, T2, T3, TContext>
): RequestHandler<T1, T2, T3, TContext> {
return function enforceSuperHandler(context, req, res) {
if (!appContextService.hasSecurity() || !appContextService.getSecurityLicense().isEnabled()) {
const isSuperuser = checkSuperuser(req);
if (!isSuperuser) {
return res.forbidden({
body: {
message: `Access to this API requires that security is enabled`,
message: SUPERUSER_AUTHZ_MESSAGE,
},
});
}
const security = appContextService.getSecurity();
const user = security.authc.getCurrentUser(req);
if (!user) {
return res.forbidden({
body: {
message:
'Access to Fleet API require the superuser role, and for stack security features to be enabled.',
},
});
}
const userRoles = user.roles || [];
if (!userRoles.includes('superuser')) {
return res.forbidden({
body: {
message: 'Access to Fleet API require the superuser role.',
},
});
}
return handler(context, req, res);
};
}
export function makeRouterEnforcingSuperuser<TContext extends RequestHandlerContext>(
function makeRouterEnforcingSuperuser<TContext extends RequestHandlerContext>(
router: IRouter<TContext>
): IRouter<TContext> {
return {
get: (options, handler) => router.get(options, enforceSuperUser(handler)),
delete: (options, handler) => router.delete(options, enforceSuperUser(handler)),
post: (options, handler) => router.post(options, enforceSuperUser(handler)),
put: (options, handler) => router.put(options, enforceSuperUser(handler)),
patch: (options, handler) => router.patch(options, enforceSuperUser(handler)),
get: (options, handler) => router.get(options, enforceSuperuser(handler)),
delete: (options, handler) => router.delete(options, enforceSuperuser(handler)),
post: (options, handler) => router.post(options, enforceSuperuser(handler)),
put: (options, handler) => router.put(options, enforceSuperuser(handler)),
patch: (options, handler) => router.patch(options, enforceSuperuser(handler)),
handleLegacyErrors: (handler) => router.handleLegacyErrors(handler),
getRoutes: () => router.getRoutes(),
routerPath: router.routerPath,
};
}
async function checkFleetSetupPrivilege(req: KibanaRequest) {
if (!checkSecurityEnabled()) {
return false;
}
const security = appContextService.getSecurity();
if (security.authz.mode.useRbacForRequest(req)) {
const checkPrivileges = security.authz.checkPrivilegesDynamicallyWithRequest(req);
const { hasAllRequested } = await checkPrivileges(
{ kibana: [security.authz.actions.api.get('fleet-setup')] },
{ requireLoginAction: false } // exclude login access requirement
);
return !!hasAllRequested;
}
return true;
}
function enforceFleetSetupPrivilege<P, Q, B, TContext extends RequestHandlerContext>(
handler: RequestHandler<P, Q, B, TContext>
): RequestHandler<P, Q, B, TContext> {
return async (context, req, res) => {
const hasFleetSetupPrivilege = await checkFleetSetupPrivilege(req);
if (!hasFleetSetupPrivilege) {
return res.forbidden({ body: { message: SUPERUSER_AUTHZ_MESSAGE } });
}
return handler(context, req, res);
};
}
function makeRouterEnforcingFleetSetupPrivilege<TContext extends RequestHandlerContext>(
router: IRouter<TContext>
): IRouter<TContext> {
return {
get: (options, handler) => router.get(options, enforceFleetSetupPrivilege(handler)),
delete: (options, handler) => router.delete(options, enforceFleetSetupPrivilege(handler)),
post: (options, handler) => router.post(options, enforceFleetSetupPrivilege(handler)),
put: (options, handler) => router.put(options, enforceFleetSetupPrivilege(handler)),
patch: (options, handler) => router.patch(options, enforceFleetSetupPrivilege(handler)),
handleLegacyErrors: (handler) => router.handleLegacyErrors(handler),
getRoutes: () => router.getRoutes(),
routerPath: router.routerPath,
};
}
export type RouterWrapper = <T extends RequestHandlerContext>(route: IRouter<T>) => IRouter<T>;
interface RouterWrappersSetup {
require: {
superuser: RouterWrapper;
fleetSetupPrivilege: RouterWrapper;
};
}
export const RouterWrappers: RouterWrappersSetup = {
require: {
superuser: (router) => {
return makeRouterEnforcingSuperuser(router);
},
fleetSetupPrivilege: (router) => {
return makeRouterEnforcingFleetSetupPrivilege(router);
},
},
};

View file

@ -5,8 +5,6 @@
* 2.0.
*/
import type { RequestHandler } from 'src/core/server';
import { appContextService } from '../../services';
import type { GetFleetStatusResponse, PostFleetSetupResponse } from '../../../common';
import { setupFleet } from '../../services/setup';
@ -14,12 +12,14 @@ import { hasFleetServers } from '../../services/fleet_server';
import { defaultIngestErrorHandler } from '../../errors';
import type { FleetRequestHandler } from '../../types';
export const getFleetStatusHandler: RequestHandler = async (context, request, response) => {
export const getFleetStatusHandler: FleetRequestHandler = async (context, request, response) => {
try {
const isApiKeysEnabled = await appContextService
.getSecurity()
.authc.apiKeys.areAPIKeysEnabled();
const isFleetServerSetup = await hasFleetServers(appContextService.getInternalUserESClient());
const isFleetServerSetup = await hasFleetServers(
context.core.elasticsearch.client.asInternalUser
);
const missingRequirements: GetFleetStatusResponse['missing_requirements'] = [];
if (!isApiKeysEnabled) {

View file

@ -5,55 +5,48 @@
* 2.0.
*/
import type { IRouter } from 'src/core/server';
import { PLUGIN_ID, AGENTS_SETUP_API_ROUTES, SETUP_API_ROUTE } from '../../constants';
import type { FleetConfigType } from '../../../common';
import type { FleetRequestHandlerContext } from '../../types/request_context';
import type { FleetRouter } from '../../types/request_context';
import { getFleetStatusHandler, fleetSetupHandler } from './handlers';
export const registerFleetSetupRoute = (router: IRouter<FleetRequestHandlerContext>) => {
export const registerFleetSetupRoute = (router: FleetRouter) => {
router.post(
{
path: SETUP_API_ROUTE,
validate: false,
// if this route is set to `-all`, a read-only user get a 404 for this route
// and will see `Unable to initialize Ingest Manager` in the UI
options: { tags: [`access:${PLUGIN_ID}-read`] },
},
fleetSetupHandler
);
};
// That route is used by agent to setup Fleet
export const registerCreateFleetSetupRoute = (router: IRouter<FleetRequestHandlerContext>) => {
export const registerCreateFleetSetupRoute = (router: FleetRouter) => {
router.post(
{
path: AGENTS_SETUP_API_ROUTES.CREATE_PATTERN,
validate: false,
options: { tags: [`access:${PLUGIN_ID}-all`] },
},
fleetSetupHandler
);
};
export const registerGetFleetStatusRoute = (router: IRouter<FleetRequestHandlerContext>) => {
export const registerGetFleetStatusRoute = (router: FleetRouter) => {
router.get(
{
path: AGENTS_SETUP_API_ROUTES.INFO_PATTERN,
validate: false,
// Disable this tag and the automatic RBAC support until elastic/fleet-server access is removed in 8.0
// Required to allow elastic/fleet-server to access this API.
options: { tags: [`access:${PLUGIN_ID}-read`] },
},
getFleetStatusHandler
);
};
export const registerRoutes = (
router: IRouter<FleetRequestHandlerContext>,
config: FleetConfigType
) => {
export const registerRoutes = (router: FleetRouter, config: FleetConfigType) => {
// Ingest manager setup
registerFleetSetupRoute(router);

View file

@ -11,6 +11,7 @@ import type {
RequestHandlerContext,
RouteMethod,
SavedObjectsClientContract,
IRouter,
} from '../../../../../src/core/server';
/** @internal */
@ -37,3 +38,9 @@ export type FleetRequestHandler<
Method extends RouteMethod = any,
ResponseFactory extends KibanaResponseFactory = KibanaResponseFactory
> = RequestHandler<P, Q, B, FleetRequestHandlerContext, Method, ResponseFactory>;
/**
* Convenience type for routers in Fleet that includes the FleetRequestHandlerContext type
* @internal
*/
export type FleetRouter = IRouter<FleetRequestHandlerContext>;

View file

@ -878,6 +878,42 @@ describe('#atSpace', () => {
`);
});
});
test('omits login privilege when requireLoginAction: false', async () => {
const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient({
has_all_requested: true,
username: 'foo-username',
index: {},
application: {
[application]: {
'space:space_1': {
[mockActions.version]: true,
},
},
},
});
const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(
mockActions,
() => Promise.resolve(mockClusterClient),
application
);
const request = httpServerMock.createKibanaRequest();
const checkPrivileges = checkPrivilegesWithRequest(request);
await checkPrivileges.atSpace('space_1', {}, { requireLoginAction: false });
expect(mockScopedClusterClient.asCurrentUser.security.hasPrivileges).toHaveBeenCalledWith({
body: {
index: [],
application: [
{
application,
resources: [`space:space_1`],
privileges: [mockActions.version],
},
],
},
});
});
});
describe('#atSpaces', () => {
@ -2083,6 +2119,42 @@ describe('#atSpaces', () => {
`);
});
});
test('omits login privilege when requireLoginAction: false', async () => {
const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient({
has_all_requested: true,
username: 'foo-username',
index: {},
application: {
[application]: {
'space:space_1': {
[mockActions.version]: true,
},
},
},
});
const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(
mockActions,
() => Promise.resolve(mockClusterClient),
application
);
const request = httpServerMock.createKibanaRequest();
const checkPrivileges = checkPrivilegesWithRequest(request);
await checkPrivileges.atSpaces(['space_1'], {}, { requireLoginAction: false });
expect(mockScopedClusterClient.asCurrentUser.security.hasPrivileges).toHaveBeenCalledWith({
body: {
index: [],
application: [
{
application,
resources: [`space:space_1`],
privileges: [mockActions.version],
},
],
},
});
});
});
describe('#globally', () => {
@ -2937,4 +3009,40 @@ describe('#globally', () => {
`);
});
});
test('omits login privilege when requireLoginAction: false', async () => {
const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient({
has_all_requested: true,
username: 'foo-username',
index: {},
application: {
[application]: {
[GLOBAL_RESOURCE]: {
[mockActions.version]: true,
},
},
},
});
const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(
mockActions,
() => Promise.resolve(mockClusterClient),
application
);
const request = httpServerMock.createKibanaRequest();
const checkPrivileges = checkPrivilegesWithRequest(request);
await checkPrivileges.globally({}, { requireLoginAction: false });
expect(mockScopedClusterClient.asCurrentUser.security.hasPrivileges).toHaveBeenCalledWith({
body: {
index: [],
application: [
{
application,
resources: [GLOBAL_RESOURCE],
privileges: [mockActions.version],
},
],
},
});
});
});

View file

@ -13,6 +13,7 @@ import { GLOBAL_RESOURCE } from '../../common/constants';
import { ResourceSerializer } from './resource_serializer';
import type {
CheckPrivileges,
CheckPrivilegesOptions,
CheckPrivilegesPayload,
CheckPrivilegesResponse,
HasPrivilegesResponse,
@ -41,14 +42,20 @@ export function checkPrivilegesWithRequestFactory(
return function checkPrivilegesWithRequest(request: KibanaRequest): CheckPrivileges {
const checkPrivilegesAtResources = async (
resources: string[],
privileges: CheckPrivilegesPayload
privileges: CheckPrivilegesPayload,
{ requireLoginAction = true }: CheckPrivilegesOptions = {}
): Promise<CheckPrivilegesResponse> => {
const kibanaPrivileges = Array.isArray(privileges.kibana)
? privileges.kibana
: privileges.kibana
? [privileges.kibana]
: [];
const allApplicationPrivileges = uniq([actions.version, actions.login, ...kibanaPrivileges]);
const allApplicationPrivileges = uniq([
actions.version,
...(requireLoginAction ? [actions.login] : []),
...kibanaPrivileges,
]);
const clusterClient = await getClusterClient();
const { body } = await clusterClient.asScoped(request).asCurrentUser.security.hasPrivileges({
@ -135,18 +142,26 @@ export function checkPrivilegesWithRequestFactory(
};
return {
async atSpace(spaceId: string, privileges: CheckPrivilegesPayload) {
async atSpace(
spaceId: string,
privileges: CheckPrivilegesPayload,
options?: CheckPrivilegesOptions
) {
const spaceResource = ResourceSerializer.serializeSpaceResource(spaceId);
return await checkPrivilegesAtResources([spaceResource], privileges);
return await checkPrivilegesAtResources([spaceResource], privileges, options);
},
async atSpaces(spaceIds: string[], privileges: CheckPrivilegesPayload) {
async atSpaces(
spaceIds: string[],
privileges: CheckPrivilegesPayload,
options?: CheckPrivilegesOptions
) {
const spaceResources = spaceIds.map((spaceId) =>
ResourceSerializer.serializeSpaceResource(spaceId)
);
return await checkPrivilegesAtResources(spaceResources, privileges);
return await checkPrivilegesAtResources(spaceResources, privileges, options);
},
async globally(privileges: CheckPrivilegesPayload) {
return await checkPrivilegesAtResources([GLOBAL_RESOURCE], privileges);
async globally(privileges: CheckPrivilegesPayload, options?: CheckPrivilegesOptions) {
return await checkPrivilegesAtResources([GLOBAL_RESOURCE], privileges, options);
},
};
};

View file

@ -8,6 +8,7 @@
import { httpServerMock } from 'src/core/server/mocks';
import { checkPrivilegesDynamicallyWithRequestFactory } from './check_privileges_dynamically';
import type { CheckPrivilegesOptions } from './types';
test(`checkPrivileges.atSpace when spaces is enabled`, async () => {
const expectedResult = Symbol();
@ -25,13 +26,18 @@ test(`checkPrivileges.atSpace when spaces is enabled`, async () => {
namespaceToSpaceId: jest.fn(),
})
)(request);
const result = await checkPrivilegesDynamically({ kibana: privilegeOrPrivileges });
const options: CheckPrivilegesOptions = { requireLoginAction: true };
const result = await checkPrivilegesDynamically({ kibana: privilegeOrPrivileges }, options);
expect(result).toBe(expectedResult);
expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request);
expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, {
kibana: privilegeOrPrivileges,
});
expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(
spaceId,
{
kibana: privilegeOrPrivileges,
},
options
);
});
test(`checkPrivileges.globally when spaces is disabled`, async () => {
@ -46,9 +52,13 @@ test(`checkPrivileges.globally when spaces is disabled`, async () => {
mockCheckPrivilegesWithRequest,
() => undefined
)(request);
const result = await checkPrivilegesDynamically({ kibana: privilegeOrPrivileges });
const options: CheckPrivilegesOptions = { requireLoginAction: true };
const result = await checkPrivilegesDynamically({ kibana: privilegeOrPrivileges }, options);
expect(result).toBe(expectedResult);
expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request);
expect(mockCheckPrivileges.globally).toHaveBeenCalledWith({ kibana: privilegeOrPrivileges });
expect(mockCheckPrivileges.globally).toHaveBeenCalledWith(
{ kibana: privilegeOrPrivileges },
options
);
});

View file

@ -9,13 +9,15 @@ import type { KibanaRequest } from 'src/core/server';
import type { SpacesService } from '../plugin';
import type {
CheckPrivilegesOptions,
CheckPrivilegesPayload,
CheckPrivilegesResponse,
CheckPrivilegesWithRequest,
} from './types';
export type CheckPrivilegesDynamically = (
privileges: CheckPrivilegesPayload
privileges: CheckPrivilegesPayload,
options?: CheckPrivilegesOptions
) => Promise<CheckPrivilegesResponse>;
export type CheckPrivilegesDynamicallyWithRequest = (
@ -28,11 +30,15 @@ export function checkPrivilegesDynamicallyWithRequestFactory(
): CheckPrivilegesDynamicallyWithRequest {
return function checkPrivilegesDynamicallyWithRequest(request: KibanaRequest) {
const checkPrivileges = checkPrivilegesWithRequest(request);
return async function checkPrivilegesDynamically(privileges: CheckPrivilegesPayload) {
return async function checkPrivilegesDynamically(
privileges: CheckPrivilegesPayload,
options?: CheckPrivilegesOptions
) {
const spacesService = getSpacesService();
return spacesService
? await checkPrivileges.atSpace(spacesService.getSpaceId(request), privileges)
: await checkPrivileges.globally(privileges);
? await checkPrivileges.atSpace(spacesService.getSpaceId(request), privileges, options)
: await checkPrivileges.globally(privileges, options);
};
};
}

View file

@ -29,6 +29,18 @@ export interface HasPrivilegesResponse {
};
}
/**
* Options to influce the privilege checks.
*/
export interface CheckPrivilegesOptions {
/**
* Whether or not the `login` action should be required (default: true).
* Setting this to false is not advised except for special circumstances, when you do not require
* the request to belong to a user capable of logging into Kibana.
*/
requireLoginAction?: boolean;
}
export interface CheckPrivilegesResponse {
hasAllRequested: boolean;
username: string;
@ -59,12 +71,20 @@ export interface CheckPrivilegesResponse {
export type CheckPrivilegesWithRequest = (request: KibanaRequest) => CheckPrivileges;
export interface CheckPrivileges {
atSpace(spaceId: string, privileges: CheckPrivilegesPayload): Promise<CheckPrivilegesResponse>;
atSpace(
spaceId: string,
privileges: CheckPrivilegesPayload,
options?: CheckPrivilegesOptions
): Promise<CheckPrivilegesResponse>;
atSpaces(
spaceIds: string[],
privileges: CheckPrivilegesPayload
privileges: CheckPrivilegesPayload,
options?: CheckPrivilegesOptions
): Promise<CheckPrivilegesResponse>;
globally(
privileges: CheckPrivilegesPayload,
options?: CheckPrivilegesOptions
): Promise<CheckPrivilegesResponse>;
globally(privileges: CheckPrivilegesPayload): Promise<CheckPrivilegesResponse>;
}
export interface CheckPrivilegesPayload {

View file

@ -73,7 +73,7 @@ export default function ({ getService }: FtrProviderContext) {
'packs_read',
],
},
reserved: ['ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'],
reserved: ['fleet-setup', 'ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'],
};
await supertest

View file

@ -45,7 +45,7 @@ export default function ({ getService }: FtrProviderContext) {
},
global: ['all', 'read'],
space: ['all', 'read'],
reserved: ['ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'],
reserved: ['fleet-setup', 'ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'],
};
await supertest

View file

@ -32,12 +32,30 @@ export function getEsClientForAPIKey({ getService }: FtrProviderContext, esApiKe
});
}
export function setupFleetAndAgents({ getService }: FtrProviderContext) {
export function setupFleetAndAgents(providerContext: FtrProviderContext) {
before(async () => {
await getService('supertest').post(`/api/fleet/setup`).set('kbn-xsrf', 'xxx').send();
await getService('supertest')
// Use elastic/fleet-server service account to execute setup to verify privilege configuration
const es = providerContext.getService('es');
const {
body: { token },
// @ts-expect-error SecurityCreateServiceTokenRequest should not require `name`
} = await es.security.createServiceToken({
namespace: 'elastic',
service: 'fleet-server',
});
const supetestWithoutAuth = getSupertestWithoutAuth(providerContext);
await supetestWithoutAuth
.post(`/api/fleet/setup`)
.set('kbn-xsrf', 'xxx')
.set('Authorization', `Bearer ${token.value}`)
.send()
.expect(200);
await supetestWithoutAuth
.post(`/api/fleet/agents/setup`)
.set('kbn-xsrf', 'xxx')
.send({ forceRecreate: true });
.set('Authorization', `Bearer ${token.value}`)
.send({ forceRecreate: true })
.expect(200);
});
}

View file

@ -14,7 +14,9 @@ import { setupFleetAndAgents } from '../agents/services';
export default function (providerContext: FtrProviderContext) {
const { getService } = providerContext;
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const log = getService('log');
const es = getService('es');
describe('setup api', async () => {
skipIfNoDockerRegistry(providerContext);
@ -47,5 +49,48 @@ export default function (providerContext: FtrProviderContext) {
);
});
});
it('allows elastic/fleet-server user to call required APIs', async () => {
const {
body: { token },
// @ts-expect-error SecurityCreateServiceTokenRequest should not require `name`
} = await es.security.createServiceToken({
namespace: 'elastic',
service: 'fleet-server',
});
// elastic/fleet-server needs access to these APIs:
// POST /api/fleet/setup
// POST /api/fleet/agents/setup
// GET /api/fleet/agent_policies
// GET /api/fleet/enrollment-api-keys
// GET /api/fleet/enrollment-api-keys/<id>
await supertestWithoutAuth
.post('/api/fleet/setup')
.set('Authorization', `Bearer ${token.value}`)
.set('kbn-xsrf', 'xxx')
.expect(200);
await supertestWithoutAuth
.post('/api/fleet/agents/setup')
.set('Authorization', `Bearer ${token.value}`)
.set('kbn-xsrf', 'xxx')
.expect(200);
await supertestWithoutAuth
.get('/api/fleet/agent_policies')
.set('Authorization', `Bearer ${token.value}`)
.set('kbn-xsrf', 'xxx')
.expect(200);
const response = await supertestWithoutAuth
.get('/api/fleet/enrollment-api-keys')
.set('Authorization', `Bearer ${token.value}`)
.set('kbn-xsrf', 'xxx')
.expect(200);
const enrollmentApiKeyId = response.body.list[0].id;
await supertestWithoutAuth
.get(`/api/fleet/enrollment-api-keys/${enrollmentApiKeyId}`)
.set('Authorization', `Bearer ${token.value}`)
.set('kbn-xsrf', 'xxx')
.expect(200);
});
});
}