Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unify response interface in handler and request interceptors #42442

Merged

Conversation

mshustov
Copy link
Contributor

@mshustov mshustov commented Aug 1, 2019

Summary

Interceptor interface

The outcome of an interceptor function might be:

  • perform interceptor specific logic and pass a request further along the handler chain.
  • redirect an incoming request
  • reject an incoming request

Each interceptor has a specific toolkit. But what they have in common is the ability to redirect and reject an incoming request.

const preAuthResult = {
  next() => ...
  redirected(url: string) => ...
  rejected(error: Error, options: { statusCode?: number } = {}) => ...

Route interface

In the route handler function, redirection and rejection are parts of ResponseFactory and have completely different interface, providing ability to configure response body and headers.

const reponseFactory = {
  // ...
  redirected: (payload: HttpResponsePayload, options: RedirectResponseOptions) => ...
  badRequest: (error: ResponseError, options: HttpResponseOptions = {}) => ...
  unauthorized: (error: ResponseError, options: HttpResponseOptions = {}) => ...
 //..
};

We want both the mechanism to have a similar interface and feature set.
This PR introduces LifecycleResponseFactory which is a sub-set of ResponseFactory and supports only request redirection/rejection.
Interceptor specific outcome can be specified via toolkit passed as the last argument:

// pre-auth interceptor. toolkit methods - next, rewriteUrl.
registerOnPreAuth((req, res, toolkit) => toolkit.rewriteUrl(url))
// auth interceptor toolkit. toolkit methods - authenticated
registerAuth((req, res, toolkit) => toolkit.authenticated(...))
// post-auth interceptor. toolkit methods - next.
registerOnPostAuth((req, res, toolkit) => toolkit.next())

Checklist

Use strikethroughs to remove checklist items you don't feel are applicable to this PR.

For maintainers

Dev docs

New platform plugins may want to extend Kibana capabilities with implementing a custom logic over an incoming request, before it hits a resource endpoint. For this purpose, KIbana introduced interceptors concept. Interceptors cannot change or mutate request object directly but may redirect, reject or allow a request to pass through. An interceptor may be created for different lifecycle stages of an incoming request:

  • before a user requesting a resource is authenticated
  • authentication
  • after a user was successfully authenticated.
// pre-authentication interceptor.
registerOnPreAuth((req, res, toolkit) => {
  if(req.headers.something) return res.badRequest({ body: '"something" headers is required' });
  return toolkit.next();
})
// authentication interceptor 
registerAuth((req, res, toolkit) => {
  const authResult = authenticate(req.headers.authorization);
  if(authResult.succeeded) return toolkit.authenticated(...);
  return res.unauthorized();
});
// post-authentication interceptor.
registerOnPostAuth((req, res, toolkit) => {
  if(deps.security.getUser(req).roles.length === 0) return res.notFound();
  return toolkit.next();
});

Only route handler can respond with 2xx response.
Interceptors may redirect or reject an incoming request.
@mshustov mshustov added Team:Core Core services & architecture: plugins, logging, config, saved objects, http, ES client, i18n, etc Feature:New Platform release_note:plugin_api_changes Contains a Plugin API changes section for the breaking plugin API changes section. v7.4.0 labels Aug 1, 2019
@elasticmachine
Copy link
Contributor

Pinging @elastic/kibana-platform

registerRouter(router);

registerOnPreAuth((req, res, t) =>
res.redirected(undefined, {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably we can even go further and pass body and request options as one argument

return res.redirected({ headers: .... })
return res.ok({
  body: {},
  headers: ....
})

@elastic/kibana-platform

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to me to make this API a bit less awkward. Do you think we should do that for all of these methods for consistency?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we should do that for all of these methods for consistency?

Yes. I can do this in a separate PR.

.get('/')
.expect(200, 'ok');

expect(callingOrder).toEqual(['first', 'second']);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I focused more on the integration tests to analyze the observable behavior of the system. Otherwise, our tests are tightly coupled with hapi library.

}

private toError(kibanaResponse: KibanaResponse<ResponseError>) {
const { payload } = kibanaResponse;
return this.responseToolkit
Copy link
Contributor Author

@mshustov mshustov Aug 1, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

had to switch to Boom error. hapi doesn't allow to respond from lifecycle event handlers. even for error responses 😞

@@ -88,7 +88,7 @@ export async function setupAuthentication({
authenticationResult = await authenticator.authenticate(request);
} catch (err) {
authLogger.error(err);
return t.rejected(wrapError(err));
return response.unauthorized(wrapError(err));
Copy link
Contributor Author

@mshustov mshustov Aug 1, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's not 1-1 mapping. Before we passed on all ES error to the client side. we can add this logic to support rejecting with any error and status or always return something we have control over, say unauthorized/internal error. want to hear your opinion. @elastic/kibana-security @azasypkin

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I remember returning 401 will force user to logout, I'd rather return internal server error from this catch as it's unexpected authentication flow.

Copy link
Contributor Author

@mshustov mshustov Aug 2, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

type AuthResult = Authenticated | Rejected | Redirected;
type AuthResult = Authenticated;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can be as simple as Authenticated. The current implementation hides details and allows us to introduce additional logic without affecting the interface.

if (preAuthResult.isRedirected(result)) {
const { url, forward } = result;
if (forward) {
if (preAuthResult.isValid(result)) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: remove this check

@elasticmachine
Copy link
Contributor

💔 Build Failed

@mshustov mshustov marked this pull request as ready for review August 1, 2019 14:39
@mshustov mshustov requested review from a team as code owners August 1, 2019 14:39
Copy link
Contributor

@eliperelman eliperelman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good from an implementation standpoint. Have a couple optional nits.

@joshdover
Copy link
Member

ack: will get to tomorrow

@@ -105,28 +105,25 @@ export async function setupAuthentication({
// authentication (username and password) or arbitrary external page managed by 3rd party
// Identity Provider for SSO authentication mechanisms. Authentication provider is the one who
// decides what location user should be redirected to.
return t.redirected(authenticationResult.redirectURL!);
return response.redirected(undefined, {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: feels a bit weird to have a dedicated redirected method, but leverage optional generic headers for the required location header.... But functionally it works for us, so 🤷‍♂️

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kinda agree. However, we did it to be able to send a payload with redirection response and to comply with the other methods signature (payload, options) => KibanaResponse

@elasticmachine
Copy link
Contributor

💔 Build Failed

@mshustov mshustov force-pushed the issue-41996-response-factory-to-interceptors branch from 2cedbb4 to 8f9ecbd Compare August 2, 2019 12:04
@elasticmachine
Copy link
Contributor

💔 Build Failed

@mshustov mshustov force-pushed the issue-41996-response-factory-to-interceptors branch from 869cf56 to fba3b7a Compare August 2, 2019 13:56
@elasticmachine
Copy link
Contributor

💚 Build Succeeded

@joshdover joshdover added this to Code review in kibana-core [DEPRECATED] Aug 2, 2019
src/core/server/http/lifecycle/auth.ts Outdated Show resolved Hide resolved
src/core/server/http/lifecycle/auth.ts Outdated Show resolved Hide resolved
registerRouter(router);

registerOnPreAuth((req, res, t) =>
res.redirected(undefined, {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to me to make this API a bit less awkward. Do you think we should do that for all of these methods for consistency?

// Hapi typings don't support headers that are `string[]`.
error.output.headers[headerName] = headerValue as any;
}
const error = authenticationResult.error;
Copy link
Member

@azasypkin azasypkin Aug 7, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

potential issue: if the change in behavior above is okay (internalError for unexpected errors), here it's a bit different, ideally we should return the same status Elasticsearch gives us during authentication, e.g. 500 or whatever ES may return in the future (since we're basically intermediate party between user and Elasticsearch in the authentication flow). Ideally 401 should only be returned when ES returns error with such status or when we can't handle authentication (notHandled). It feels like we need something like wrapError in the core/responseFactory....

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, that question I asked here #42442 (comment) ok, I will add customizable error handler

Copy link
Member

@azasypkin azasypkin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes in Spaces look good, but left a couple of comments/questions in security part.

@elasticmachine
Copy link
Contributor

💚 Build Succeeded

Copy link
Contributor

@rudolf rudolf left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a couple of comments on the types, but these aren't directly linked to this PR, so might be best to address in a different PR.

@@ -401,31 +404,22 @@ export interface LogRecord {
// Warning: (ae-forgotten-export) The symbol "OnPostAuthResult" needs to be exported by the entry point index.d.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we export this?

Copy link
Contributor Author

@mshustov mshustov Aug 7, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OnPostAuthResult, OnPreAuthResult, RouterRoute
they are internal types of the core, plugins don't have access to them.

redirected: (url: string) => OnPostAuthResult;
rejected: (error: Error, options?: {
statusCode?: number;
}) => OnPostAuthResult;
}

// Warning: (ae-forgotten-export) The symbol "OnPreAuthResult" needs to be exported by the entry point index.d.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing export?

@@ -535,16 +529,14 @@ export class Router {
constructor(path: string);
delete<P extends ObjectType, Q extends ObjectType, B extends ObjectType>(route: RouteConfig<P, Q, B>, handler: RequestHandler<P, Q, B>): void;
get<P extends ObjectType, Q extends ObjectType, B extends ObjectType>(route: RouteConfig<P, Q, B>, handler: RequestHandler<P, Q, B>): void;
// Warning: (ae-forgotten-export) The symbol "RouterRoute" needs to be exported by the entry point index.d.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing export?

@@ -74,6 +74,7 @@ export {
IsAuthenticated,
KibanaRequest,
KibanaRequestRoute,
LifecycleResponseFactory,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we prefix these types with Http... to stick with our convention to avoid naming conflicts and make them more discoverable? (Also applies to the other HTTP types, so would probably make sense to do it in a different PR)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

babel v7.5+ supports typescript namespaces. probably we can make another attempt to switch to them. for now, we can use manual convention.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well that sure is exciting.

@elasticmachine
Copy link
Contributor

💔 Build Failed

@mshustov mshustov requested a review from azasypkin August 7, 2019 13:35
@mshustov
Copy link
Contributor Author

mshustov commented Aug 7, 2019

retest

@elasticmachine
Copy link
Contributor

💚 Build Succeeded

Copy link
Member

@azasypkin azasypkin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security and Spaces changes LGTM

@elasticmachine
Copy link
Contributor

💚 Build Succeeded

@mshustov mshustov merged commit 06adc73 into elastic:master Aug 8, 2019
kibana-core [DEPRECATED] automation moved this from Code review to Needs Backport Aug 8, 2019
@mshustov mshustov deleted the issue-41996-response-factory-to-interceptors branch August 8, 2019 10:07
mshustov added a commit to mshustov/kibana that referenced this pull request Aug 8, 2019
…#42442)

* add response factory to the interceptors

* adopt x-pack code to the changes

* Add a separate response factory for lifecycles.

Only route handler can respond with 2xx response.
Interceptors may redirect or reject an incoming request.

* re-generate docs

* response.internal --> response.internalError

* use internalError for exceptions in authenticator

* before Security plugin proxied ES error status code. now sets explicitly.

* provide error via message field of error response for BWC

* update docs

* add customError response

* restore integration test and update unit tests

* update docs

* support Hapi error format for BWC

* add a couple of tests
mshustov added a commit that referenced this pull request Aug 8, 2019
…#42918)

* add response factory to the interceptors

* adopt x-pack code to the changes

* Add a separate response factory for lifecycles.

Only route handler can respond with 2xx response.
Interceptors may redirect or reject an incoming request.

* re-generate docs

* response.internal --> response.internalError

* use internalError for exceptions in authenticator

* before Security plugin proxied ES error status code. now sets explicitly.

* provide error via message field of error response for BWC

* update docs

* add customError response

* restore integration test and update unit tests

* update docs

* support Hapi error format for BWC

* add a couple of tests
@mshustov mshustov moved this from Needs Backport to Done (this week) in kibana-core [DEPRECATED] Aug 8, 2019
@elasticmachine
Copy link
Contributor

💚 Build Succeeded

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
backported Feature:New Platform release_note:plugin_api_changes Contains a Plugin API changes section for the breaking plugin API changes section. Team:Core Core services & architecture: plugins, logging, config, saved objects, http, ES client, i18n, etc v7.4.0
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

6 participants