[http] Adds route config option access flag to indicate if an API is public or internal (#152404)

This commit is contained in:
Christiane (Tina) Heiligers 2023-03-02 08:14:59 -07:00 committed by GitHub
parent 0c5fb37670
commit 41f7e633a9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 96 additions and 5 deletions

View file

@ -201,6 +201,7 @@ export class CoreKibanaRequest<
xsrfRequired:
((request.route?.settings as RouteOptions)?.app as KibanaRouteOptions)?.xsrfRequired ??
true, // some places in LP call KibanaRequest.from(request) manually. remove fallback to true before v8
access: this.getAccess(request),
tags: request.route?.settings?.tags || [],
timeout: {
payload: payloadTimeout,
@ -222,6 +223,13 @@ export class CoreKibanaRequest<
options,
};
}
/** infer route access from path if not declared */
private getAccess(request: RawRequest): 'internal' | 'public' {
return (
((request.route?.settings as RouteOptions)?.app as KibanaRouteOptions)?.access ??
(request.path.startsWith('/internal') ? 'internal' : 'public')
);
}
private getAuthRequired(request: RawRequest): boolean | 'optional' {
if (isFakeRawRequest(request)) {

View file

@ -71,7 +71,7 @@ function createKibanaRequestMock<P = any, Q = any, B = any>({
routeTags,
routeAuthRequired,
validation = {},
kibanaRouteOptions = { xsrfRequired: true },
kibanaRouteOptions = { xsrfRequired: true, access: 'public' },
kibanaRequestState = {
requestId: '123',
requestUuid: '123e4567-e89b-12d3-a456-426614174000',

View file

@ -817,6 +817,56 @@ test('allows attaching metadata to attach meta-data tag strings to a route', asy
await supertest(innerServer.listener).get('/without-tags').expect(200, { tags: [] });
});
test('allows declaring route access to flag a route as public or internal', async () => {
const access = 'internal';
const { registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('', logger, enhanceWithContext);
router.get({ path: '/with-access', validate: false, options: { access } }, (context, req, res) =>
res.ok({ body: { access: req.route.options.access } })
);
router.get({ path: '/without-access', validate: false }, (context, req, res) =>
res.ok({ body: { access: req.route.options.access } })
);
registerRouter(router);
await server.start();
await supertest(innerServer.listener).get('/with-access').expect(200, { access });
await supertest(innerServer.listener).get('/without-access').expect(200, { access: 'public' });
});
test('infers access flag from path if not defined', async () => {
const { registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('', logger, enhanceWithContext);
router.get({ path: '/internal/foo', validate: false }, (context, req, res) =>
res.ok({ body: { access: req.route.options.access } })
);
router.get({ path: '/random/foo', validate: false }, (context, req, res) =>
res.ok({ body: { access: req.route.options.access } })
);
router.get({ path: '/random/internal/foo', validate: false }, (context, req, res) =>
res.ok({ body: { access: req.route.options.access } })
);
router.get({ path: '/api/foo/internal/my-foo', validate: false }, (context, req, res) =>
res.ok({ body: { access: req.route.options.access } })
);
registerRouter(router);
await server.start();
await supertest(innerServer.listener).get('/internal/foo').expect(200, { access: 'internal' });
await supertest(innerServer.listener).get('/random/foo').expect(200, { access: 'public' });
await supertest(innerServer.listener)
.get('/random/internal/foo')
.expect(200, { access: 'public' });
await supertest(innerServer.listener)
.get('/api/foo/internal/my-foo')
.expect(200, { access: 'public' });
});
test('exposes route details of incoming request to a route handler', async () => {
const { registerRouter, server: innerServer } = await server.setup(config);
@ -833,6 +883,7 @@ test('exposes route details of incoming request to a route handler', async () =>
options: {
authRequired: true,
xsrfRequired: false,
access: 'public',
tags: [],
timeout: {},
},
@ -1010,6 +1061,7 @@ test('exposes route details of incoming request to a route handler (POST + paylo
options: {
authRequired: true,
xsrfRequired: true,
access: 'public',
tags: [],
timeout: {
payload: 10000,

View file

@ -524,6 +524,7 @@ export class HttpServer {
const kibanaRouteOptions: KibanaRouteOptions = {
xsrfRequired: route.options.xsrfRequired ?? !isSafeMethod(route.method),
access: route.options.access ?? (route.path.startsWith('/internal') ? 'internal' : 'public'),
};
this.server!.route({

View file

@ -167,6 +167,7 @@ describe('xsrf post-auth handler', () => {
path: '/some-path',
kibanaRouteOptions: {
xsrfRequired: false,
access: 'public',
},
});

View file

@ -60,6 +60,7 @@ export const createVersionCheckPostAuthHandler = (kibanaVersion: string): OnPost
};
};
// TODO: implement header required for accessing internal routes. See https://github.com/elastic/kibana/issues/151940
export const createCustomHeadersPreResponseHandler = (config: HttpConfig): OnPreResponseHandler => {
const {
name: serverName,

View file

@ -19,6 +19,7 @@ import type { Headers } from './headers';
*/
export interface KibanaRouteOptions extends RouteOptionsApp {
xsrfRequired: boolean;
access: 'internal' | 'public';
}
/**

View file

@ -120,6 +120,18 @@ export interface RouteConfigOptions<Method extends RouteMethod> {
*/
xsrfRequired?: Method extends 'get' ? never : boolean;
/**
* Defines intended request origin of the route:
* - public. The route is public, declared stable and intended for external access.
* In the future, may require an incomming request to contain a specified header.
* - internal. The route is internal and intended for internal access only.
*
* If not declared, infers access from route path:
* - access =`internal` for '/internal' route path prefix
* - access = `public` for everything else
*/
access?: 'public' | 'internal';
/**
* Additional metadata tag strings to attach to the route.
*/

View file

@ -22,7 +22,7 @@ const versionedRouter = vtk.createVersionedRouter({ router });
const versionedRoute = versionedRouter
.post({
path: '/api/my-app/foo/{id?}',
options: { timeout: { payload: 60000 } },
options: { timeout: { payload: 60000 }, access: 'public' },
})
.addVersion(
{

View file

@ -13,6 +13,7 @@ import type {
RequestHandler,
RouteValidatorFullConfig,
RequestHandlerContextBase,
RouteConfigOptions,
} from '@kbn/core-http-server';
type RqCtx = RequestHandlerContextBase;
@ -45,7 +46,7 @@ export interface CreateVersionedRouterArgs<Ctx extends RqCtx = RqCtx> {
* const versionedRoute = versionedRouter
* .post({
* path: '/api/my-app/foo/{id?}',
* options: { timeout: { payload: 60000 } },
* options: { timeout: { payload: 60000 }, access: 'public' },
* })
* .addVersion(
* {
@ -99,14 +100,28 @@ export interface VersionHTTPToolkit {
): VersionedRouter<Ctx>;
}
/**
* Converts an input property from optional to required. Needed for making RouteConfigOptions['access'] required.
*/
type WithRequiredProperty<Type, Key extends keyof Type> = Type & {
[Property in Key]-?: Type[Property];
};
/**
* Versioned route access flag, required
* - '/api/foo' is 'public'
* - '/internal/my-foo' is 'internal'
* Required
*/
type VersionedRouteConfigOptions = WithRequiredProperty<RouteConfigOptions<RouteMethod>, 'access'>;
/**
* Configuration for a versioned route
* @experimental
*/
export type VersionedRouteConfig<Method extends RouteMethod> = Omit<
RouteConfig<unknown, unknown, unknown, Method>,
'validate'
>;
'validate' | 'options'
> & { options: VersionedRouteConfigOptions };
/**
* Create an {@link VersionedRoute | versioned route}.