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

Add API to refresh authc headers and retry ES request when 401 is encountered #120677

Merged
merged 24 commits into from
Jan 18, 2022

Conversation

pgayvallet
Copy link
Contributor

@pgayvallet pgayvallet commented Dec 7, 2021

Summary

POC to discuss design and implementation of #108346 (and related to #104893)
Fix #108346

Add a new setUnauthorizedErrorHandler public API to ElasticsearchServiceSetup, to allow registering a handler that will be invoked when a scoped ES client returns a 401 for any API call.

The handler can return 2 results: retry or notHandled

  • When a retry result is returned:

    • we update the httpServer's authRequestHeaders bound to the scoped client's request with the returned headers
    • we update the scoped ES Client's headers with the returned headers and try the request again
      • if the request fails again, we do not reiterate the process
  • When a notHandled result is returned:

    • The underlying error is surfaced to the ES client's API consumer as if no handler was present
  • If the handler throws or rejects, we behave as if notHandled was returned.

Usage would look like:

const handler: UnauthorizedErrorHandler = ({ request, error }, toolkit) => {
   const reauthenticationResult = await authenticator.reauthenticate(request, error);
   if (reauthenticationResult.succeeded()) {
     return toolkit.retry({
       authHeaders: reauthenticationResult.authHeaders,
     });
  }
  return toolkit.notHandled();
}

coreSetup.elasticsearch.setUnauthorizedErrorHandler(handler);

Copy link
Contributor Author

@pgayvallet pgayvallet left a comment

Choose a reason for hiding this comment

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

Discussion items and remarks about the design and the proposed implementation

package.json Outdated Show resolved Hide resolved
Comment on lines 401 to 402
getAuthHeaders: GetAuthHeaders;
setAuthHeaders: SetAuthHeaders;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

As @mshustov already mentioned on the issue, now that we need to update the headers from outside of the http/login lifecycle, it would probably make sense to extract parts of the authc from the http service. Maybe a new auth internal service?

To start simple though, I was thinking of extracting all the Request->AuthState storages from httpServer, or at least to create a higher-level API/storage to keep/isolate them in a single place. We could KISS and keep this new AuthStorage in the http server/service for now, and just expose it on the internal http contract to be used by elasticsearch service.

The AuthStorage would be responsible to keep the state of what is currently stored in 3 different objects:

private readonly authState: AuthStateStorage;
private readonly authRequestHeaders: AuthHeadersStorage;
private readonly authResponseHeaders: AuthHeadersStorage;

Comment on lines 58 to 62
export interface ElasticsearchServiceSetup {
/**
* TODO: doc
*/
setUnauthorizedErrorHandler: (handler: UnauthorizedErrorHandler) => void;
Copy link
Contributor Author

@pgayvallet pgayvallet Dec 8, 2021

Choose a reason for hiding this comment

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

The entrypoint for the new API, to be consumed by the security plugin.

@elastic/kibana-security could you check the signature of the API (and of the UnauthorizedErrorHandler types), and tell us if the API seems acceptable for your needs?

As discussed sync on slack, and explained on the issue's description, the handler currently only allow to update the authRequestHeaders (and not authResponseHeaders or authState)

Copy link
Member

@azasypkin azasypkin Dec 14, 2021

Choose a reason for hiding this comment

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

TL;DR: the API looks good to me, thanks!


As I mentioned in Slack, we can safely ignore authResponseHeaders since we use it only during Kerberos "handshake" that we don't want to perform in this case.

I also confirmed with the ES security team that during refresh the user info (== authState, Kibana Security plugin leverages the core's authState whenever it wants to know who the current user is and what roles they have, it's also a part of public Security plugin API that other plugins rely on) should not change for Token, SAML, OIDC and Kerberos authentication (it's cached in .security-tokens index) which is great.

The only case when user info can potentially change during refresh is PKI authentication since instead of using refresh token (we don't have one for PKI) we exchange existing client certificate to a new token. We know that certificate is the same (otherwise we should have caught that during authentication stage), and the chance that the role mappings have changed during access token lifetime (max 60 minutes) is very low.

Even though it'd be nice to update authState during refresh as well (for the consistency at least), considering all the above it feels safe enough to ignore it assuming it requires significant effort to support (is it correct assessment?).

Comment on lines 65 to 67
updateHeaders(headers: AuthHeaders) {
this[kHeaders] = { ...this[kHeaders], ...(headers as IncomingHttpHeaders) };
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

When the handler returns a retry result with updated authorization headers, I update the Client's headers (using the workaround from @delvedor #108346 (comment))

Note that I currently keep the existing headers and just spread the new ones. I wonder if we should rerun the ClusterClient.getScopedHeaders logic and then totally replace the client's headers instead?

Copy link
Contributor

@mshustov mshustov Dec 10, 2021

Choose a reason for hiding this comment

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

I wonder if we should rerun the ClusterClient.getScopedHeaders logic and then totally replace the client's headers instead?

I don't think it's safe. elasticsearch-js can set client-specific headers. Here it sets headers during the instantiation process.
https://github.com/elastic/elasticsearch-js/blob/main/src/client.ts#L202
https://github.com/elastic/elastic-transport-js/blob/main/src/Transport.ts#L228

Maybe Transport can support updateHeaders API in the future? @delvedor

Comment on lines 50 to 61
try {
return (await super.request(params, opts)) as TransportResult<any, any>;
} catch (e) {
if (isUnauthorizedError(e) && unauthorizedErrorHandler) {
const result = await unauthorizedErrorHandler(e);
if (isRetryResult(result)) {
const headers = result.authHeaders;
this.updateHeaders(headers);
return (await super.request(params, opts)) as TransportResult<any, any>;
}
}
throw e;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the logic of the retry mechanism implemented directly into the client's Transport.

Comment on lines 119 to 126
const transportClass = createTransport({
getExecutionContext: this.getExecutionContext,
unauthorizedErrorHandler: internalErrorHandler,
});

const scopedClient = this.rootScopedClient.child({
headers: scopedHeaders,
Transport: transportClass,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Due to each client being bound to a specific request, we're forced to create a new transport class for every scoped client. However I don't think a class is treated differently than any object in javascript, so I don't think this is a danger for memory usage or leak.

As a side note, I think in the final implementation we may want to move the logic related to the scoped client (both the part that was already present such as getScopedHeaders and the new logic introduced in this PR) from ClusterClient to ScopedClusterClient for better isolation of concerns.

Copy link
Member

Choose a reason for hiding this comment

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

so I don't think this is a danger for memory usage or leak.

The connection pool is shared among child clients, so you won't leak file descriptors.

Comment on lines 82 to 84
if (!isRealRequest(request)) {
return () => toolkit.notHandled();
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This part is to address

Only when request to Elasticsearch is attributed with Authorization header provided by the AuthenticationHandler (the one registered via registerAuth hook). If consumers override client's Authorization header with FakeRequest and alike we shouldn't interfere.
Only when request isn't made by the Security plugin itself (otherwise we can get into infinite recursion 🙂 ). This will be probably solved automatically by the solution for the previous point since we always override Authorization header in security plugin. But if not, maybe we'd need a special TransportRequestOptions option?

from the initial requirements expressed here: #104893 (comment)

@elastic/kibana-security Could you confirm (or infirm) that disabling the handler IIF the request used for the scoped client is not a real request (KibanaRequest) would be sufficient?

If it's not, could you provide the logic we want to use to disable the handler depending on the request? We would ideally have this condition ONLY depends on the request (and not on the state of the request auth headers (httpServer.authRequestHeaders) associated to it. But if required, I can adapt the code to also access the auth storage from this function.

Copy link
Contributor

Choose a reason for hiding this comment

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

@elastic/kibana-security Could you confirm (or infirm) that disabling the handler IIF the request used for the scoped client is not a real request (KibanaRequest) would be sufficient?

If the proposed solution works for the Kibana Security team, 👍 from me. That was not the simplest logic to implement 😅

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, I believe it should be enough, here is the only place I'm aware of where we'd want to prevent automatic retry logic and we don't use real request there (.asScoped({ headers: { ...request.headers, ...authHeaders } })).

}
return async (error) => {
try {
const result = await handler({ request, error }, toolkit);
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'm not sure this is really an issue, but I need to mention it: we're now keeping a reference to request within this closure/handler, meaning that we keep a reference between the ScopedClusterClient and the associated request. Previously it wasn't the case, as the request was just used to generate the headers via getScopedHeaders and then the reference could be GC'ed as the end of ClusterClient.asScoped.

This means that any reference kept on the instance of the ClusterClient that was created to instantiate the ScopedClusterClient via asScoped will block the GC from disposing of the request object. In theory, it shouldn't be an issue as these scoped clients are supposed to be kept only for the duration of a request (created in the request handler, meaning that it can be disposed at the end of the execution of the handler).

I wonder if we should use a WeakRef internally, as done with WeakMap for AuthHeadersStorage, just to be safe?

Now, if we were to do that, that would we do if the request has been GC'ed when entering the handler? Just return notHandled as we do if !isRealRequest(request)?

Copy link
Contributor

Choose a reason for hiding this comment

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

In theory, it shouldn't be an issue as these scoped clients are supposed to be kept only for the duration of a request (created in the request handler, meaning that it can be disposed at the end of the execution of the handler).

I think we designed most of our API relying on this condition.

Comment on lines +88 to +91
if (isRetryResult(result)) {
setAuthHeaders(request, result.authHeaders);
}
return 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.

These 3 lines are probably the most important part. This createInternalErrorHandler function converts the UnauthorizedErrorHandler as registered by our API consumer (e.g security) to the InternalUnauthorizedErrorHandler used by the ES client.

When the client encounter a 401, it will call the internal handler, which will call the registered handler, and then, if the result is a retry, it will update the auth headers (httpServer.authRequestHeaders) before forwarding the result to the ES client.

The ES client, in case of retry, will then update its headers and perform the request again.

try {
const result = await handler({ request, error }, toolkit);
if (isRetryResult(result)) {
setAuthHeaders(request, result.authHeaders);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

setAuthHeaders just updates the authRequestHeaders entry for the given request with the returned headers.

I initially adapted AuthHeadersStorage to hold an observable of the AuthHeaders

-- private authHeadersCache = new WeakMap<Request, AuthHeaders>();
++ private authHeadersCache = new WeakMap<Request, Observable<AuthHeaders>>();

to allow reactively responding to auth headers changes. This would have potentially allowed to have multiple scoped clients bound to the same request to reactively update their headers when any client is updated.

e.g (not working in current implementation)

const client1 = clusterClient.asScoped(request);
const client2 = clusterClient.asScoped(request);

// causing the 401 scenario because of expired access token
const responseReturnedAfterRetry =  await client1.search({});

// client2 should ideally already be updated with the new headers and avoid to retrigger the 401
await client2.search({});

However, I couldn't find any other scenario where being able to subscribe to auth headers change would provide any value, so I reverted the change to remove the increased complexity.

WDYT? Do we have a reason/need to change the AuthHeadersStorage implementation to hold an observable instead of only the current value of the auth headers?

Copy link
Contributor

Choose a reason for hiding this comment

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

Considering #108346 (comment). I'd say we can live with it.

Copy link
Member

@delvedor delvedor left a comment

Choose a reason for hiding this comment

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

Another solution to avoid playing with the internal symbols is to keep track of the headers in your own custom transport and pass them via transport request options when needed.

eg:

class KibanaTransport extends Transport {
 constructor (opts) {
    super(opts)
    this.kibanaHeaders = {}
  }

  async request (params, options = {}) {
    options.headers = { ...options.headers, ...this.kibanaHeaders }
    try {
      return await super.request(params, options)
    } catch (err) {
      // update this.kibanaHeaders if needed
    }
  }
}

@pgayvallet
Copy link
Contributor Author

Another solution to avoid playing with the internal symbols is to keep track of the headers in your own custom transport and pass them via transport request options when needed.

Yea, I thought about this after my initial POC, and that's probably the way to go. We'll need to remove the headers passed in the constructor to the parent's call though

constructor (opts) {
    const { headers, ...superOpts } = opts
    super(superOpts)
    this.kibanaHeaders = headers ?? {};
  }

but this avoid messing with internals of the Client, so that's probably way more elegant.

@@ -399,6 +399,7 @@ export interface InternalHttpServiceSetup
registerRouterAfterListening: (router: IRouter) => void;
registerStaticDir: (path: string, dirPath: string) => void;
getAuthHeaders: GetAuthHeaders;
setAuthHeaders: SetAuthHeaders;
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe it is simpler to pass a single AuthHeadersStorage interface instead of passing its methods?
We can continue keeping the Auth service in Http for time being.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yea, just waiting for @elastic/kibana-security to confirm that only updating the request headers is sufficient (no response headers and no auth state), in which case I agree that introducing a IAuthHeadersStorage interface and passing it down to the ES service seems the most pragmatic solution.

Copy link
Member

Choose a reason for hiding this comment

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

Replied in https://github.com/elastic/kibana/pull/120677/files#r768617303. If updating authState in addition to authRequestHeaders requires significant changes or just too risky, we can move forward with just updating authRequestHeaders.

setAuthHeaders: SetAuthHeaders;
}): InternalUnauthorizedErrorHandler => {
// we don't want to support 401 retry for fake requests
if (!isRealRequest(request)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we keep this logic isolated in auth service? it rather belongs there.

Copy link
Contributor Author

@pgayvallet pgayvallet Dec 10, 2021

Choose a reason for hiding this comment

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

Well, this is impacting the ES client's behavior, as we need to totally disable the logic and not retry the 401 in case the provided request is not a 'real' one, so I don't really see a way to isolate this in the auth/http service.

We could eventually just check perform this check in ClusterClient.asScoped, and, if it's a fake request, just not create the handler at all, but this will be be done inside the elasticsearch's service code.

Or do you have a suggestion?

Copy link
Contributor

Choose a reason for hiding this comment

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

I was thinking about something as simple as:

class Auth {
  canBeReAuthenticated(request) {
    return isRealRequest(request)
  }
}

}
return async (error) => {
try {
const result = await handler({ request, error }, toolkit);
Copy link
Contributor

Choose a reason for hiding this comment

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

In theory, it shouldn't be an issue as these scoped clients are supposed to be kept only for the duration of a request (created in the request handler, meaning that it can be disposed at the end of the execution of the handler).

I think we designed most of our API relying on this condition.

try {
const result = await handler({ request, error }, toolkit);
if (isRetryResult(result)) {
setAuthHeaders(request, result.authHeaders);
Copy link
Contributor

Choose a reason for hiding this comment

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

Considering #108346 (comment). I'd say we can live with it.

this.allowListHeaders = ['x-opaque-id', ...this.config.requestHeadersWhitelist];
}

asScoped(request: ScopeableRequest) {
const scopedHeaders = this.getScopedHeaders(request);

const internalErrorHandler = this.unauthorizedErrorHandler
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we create the handler lazily? We really need it only when 401 happened.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We probably can if we want to. Do you have any concern creating is eagerly? It doesn't seems like a costly operation?

Copy link
Contributor

Choose a reason for hiding this comment

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

It's not super expensive, but asScoped might be considered as a hot path because Security uses the scoped client(s) on every incoming request. But Kibana will need internalErrorHandler handler very rarely only when 401 happens after auth lifecycle.

Comment on lines 65 to 67
updateHeaders(headers: AuthHeaders) {
this[kHeaders] = { ...this[kHeaders], ...(headers as IncomingHttpHeaders) };
}
Copy link
Contributor

@mshustov mshustov Dec 10, 2021

Choose a reason for hiding this comment

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

I wonder if we should rerun the ClusterClient.getScopedHeaders logic and then totally replace the client's headers instead?

I don't think it's safe. elasticsearch-js can set client-specific headers. Here it sets headers during the instantiation process.
https://github.com/elastic/elasticsearch-js/blob/main/src/client.ts#L202
https://github.com/elastic/elastic-transport-js/blob/main/src/Transport.ts#L228

Maybe Transport can support updateHeaders API in the future? @delvedor

src/core/server/elasticsearch/client/create_transport.ts Outdated Show resolved Hide resolved
@azasypkin
Copy link
Member

ACK: reviewing...

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.

The public core API proposed in this PR should cover what Security plugin would need to refresh access token after authentication stage, I've left a few comments with more details.

Thanks!

@@ -399,6 +399,7 @@ export interface InternalHttpServiceSetup
registerRouterAfterListening: (router: IRouter) => void;
registerStaticDir: (path: string, dirPath: string) => void;
getAuthHeaders: GetAuthHeaders;
setAuthHeaders: SetAuthHeaders;
Copy link
Member

Choose a reason for hiding this comment

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

Replied in https://github.com/elastic/kibana/pull/120677/files#r768617303. If updating authState in addition to authRequestHeaders requires significant changes or just too risky, we can move forward with just updating authRequestHeaders.

@pgayvallet pgayvallet added Feature:elasticsearch release_note:skip Skip the PR/issue when compiling release notes Team:Core Core services & architecture: plugins, logging, config, saved objects, http, ES client, i18n, etc v8.1.0 labels Dec 22, 2021
}
return async (error) => {
try {
const result = await handler({ request, error }, toolkit);
Copy link
Member

@azasypkin azasypkin Dec 30, 2021

Choose a reason for hiding this comment

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

question: I see that right now the request that is being passed to handler contains auth headers (Authorization: xxx) that were set by the Core and weren't present in the original request. Would it be possible to "unset" these headers before passing request object to the handler?

Right now Security basically skips such requests since it assumes that it was the client who specified authentication headers explicitly. We can try to workaround this in Security plugin and unset these headers on our own (not sure if it's possible since headers is readonly), but I'm wondering if you have a better way of passing original "pristine" request object instead?

Update: that actually happens because we mutate request with Authorization header after authentication stage for BWC reasons. Hmmm, I'll experiment with this a bit more to see if there anything we can do on our end.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Did you progress on your investigations?

As you said, the mutation is done here:

if (requestHeaders) {
this.authRequestHeaders.set(req, requestHeaders);
// we mutate headers only for the backward compatibility with the legacy platform.
// where some plugin read directly from headers to identify whether a user is authenticated.
Object.assign(req.headers, requestHeaders);
}

But I don't see any way to differentiate headers that have been added by this snippet from other headers, so apart from just removing this part of the code (which would likely break some stuffs relying on these headers to forge fake requests, right?) I'm not sure how we could answer your ask?

Copy link
Member

Choose a reason for hiding this comment

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

Did you progress on your investigations?

I can think of only one clumsy hack on Security side: https://github.com/elastic/kibana/pull/122155/files#diff-df4d732995701a873f86257177d6440fa562a820a0c849624cc1e54de2bbfbf1R238-R244 (Object.fromEntries(Object.entries(...)) is needed because I cannot just remove authorization header from the headers object since it's readonly).

(request.headers as Record<string, unknown>) = Object.fromEntries(
  Object.entries(request.headers).filter(
    ([headerName]) => headerName.toLowerCase() !== 'authorization'
  )
);

so apart from just removing this part of the code (which would likely break some stuffs relying on these headers to forge fake requests, right?

Yeah, reporting (and probably Upgrade Assistant in some cases) still depends on this I believe.

But I don't see any way to differentiate headers that have been added by this snippet from other headers

I was thinking that you might have a copy of the auth headers returned during authentication stage so that you know which headers are original and which were added as a part of the authentication flow, or it's not the case?

Copy link
Contributor Author

@pgayvallet pgayvallet Jan 10, 2022

Choose a reason for hiding this comment

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

I was thinking that you might have a copy of the auth headers returned during authentication stage so that you know which headers are original and which were added as a part of the authentication flow, or it's not the case?

Nope, not really.

Well, we do set the headers returned from the auth hook inside the authRequestHeaders map, so in a way, we can identify which entries in req.headers were injected by looking at the keys of the associated entry in authRequestHeaders, but:

  • This will be quite ugly if we do that from the ES error interception logic. We're already leaking responsibilities all over the place, and it will just increase it.
  • In case of overrides (e.g a header returned from the login hook overriding a header initially present on the request), we won't be able to provide the initial value of such headers.
  • KibanaRequest is immutable, and we won't be able to easily recreate one to provide it to the hook

In addition, the login HAPI hook is performed very early, before we actually wrap HAPI's request to a KibanaRequest, so if we want to store these original headers somewhere, we'll need to do that (also) directly on the base request. We would be forced to do something like

diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts
--- a/src/core/server/http/http_server.ts	(revision f6370f413172821ea8e06dfc0d80ff5b5a6e90bd)
+++ b/src/core/server/http/http_server.ts	(date 1641814909929)
@@ -448,6 +448,7 @@
             this.authRequestHeaders.set(req, requestHeaders);
             // we mutate headers only for the backward compatibility with the legacy platform.
             // where some plugin read directly from headers to identify whether a user is authenticated.
+            req.app.originalHeaders = { ...req.headers };
             Object.assign(req.headers, requestHeaders);
           }
         }

and to expose these originalHeaders as a new property of KibanaRequest.

This is doable, but I would take any other alternative if possible to be honest.

@mshustov wdyt?

Copy link
Member

Choose a reason for hiding this comment

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

This will be quite ugly if we do that from the ES error interception logic. We're already leaking responsibilities all over the place, and it will just increase it.

Yeah, I see. It feels like there is no way to avoid an ugly workaround of some sort, it's just a matter where it will live. I'm fine with keeping it in Security if there is no elegant way to accomplish this directly in the Core.

In case of overrides (e.g a header returned from the login hook overriding a header initially present on the request), we won't be able to provide the initial value of such headers.

It's something that should never happen, at least Security will never override existing headers.

KibanaRequest is immutable, and we won't be able to easily recreate one to provide it to the hook

If we agree to keep workaround (e.g. rewriting request.headers), is it safe to assume that request.headers will stay writable?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

is it safe to assume that request.headers will stay writable?

KibanaRequest.headers is defined as readonly

public readonly headers: Headers;

and its value is frozen

this.headers = deepFreeze({ ...request.headers });

however as discussed sync, the request.headers property is not technically frozen and can be overriden if forcecasted.

@pgayvallet pgayvallet marked this pull request as ready for review January 10, 2022 14:33
@elasticmachine
Copy link
Contributor

Pinging @elastic/kibana-core (Team:Core)

@pgayvallet pgayvallet requested review from a team, mshustov and azasypkin January 10, 2022 14:33
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.

The API shape looks good to me, the WIP base integration test suite is passing with this PR as well.

As we agreed, we'll be filtering out authorization header from the request instance before re-authentication in the Security plugin for now. Once we stop mutating requests with the headers returned at the authentication stage, we'll be able to get rid of this workaround.

Thanks!

Copy link
Contributor

@mshustov mshustov left a comment

Choose a reason for hiding this comment

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

The overall design looks good. Is it possible to add an integration test to make sure everything works as expected?

src/core/server/elasticsearch/client/retry_unauthorized.ts Outdated Show resolved Hide resolved
// add stored headers to the options
opts.headers = {
...this.headers,
...optionHeaders,
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: we copy opts.headers twice.

Suggested change
...optionHeaders,
...opts.headers,

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 had incredible issues in unit tests because of the way the client keep references / mutates the headers that forced me to do that to assert against the values of the used headers for each individual call. but I will try again.

);
});

it('merges the headers from the constructor and from the options', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

could you add a test that request method doesn't mutate the arguments?

@pgayvallet
Copy link
Contributor Author

Is it possible to add an integration test to make sure everything works as expected?

We talked about it with @azasypkin and agreed that it would make more sense (and would be way easier) to add such tests in the follow-up PR from the security team that will actually leverage the feature. The FTR tests are ready (and passing) in #122155

@pgayvallet
Copy link
Contributor Author

Will be backported as it is required to address #104893 that is a bug fix

@kibana-ci
Copy link
Collaborator

💚 Build Succeeded

Metrics [docs]

Public APIs missing comments

Total count of every public API that lacks a comment. Target amount is 0. Run node scripts/build_api_docs --plugin [yourplugin] --stats comments for more detailed information.

id before after diff
core 953 968 +15
Unknown metric groups

API count

id before after diff
core 2333 2353 +20

History

To update your PR or re-run it, just comment with:
@elasticmachine merge upstream

@pgayvallet pgayvallet added the auto-backport Deprecated - use backport:version if exact versions are needed label Jan 18, 2022
@pgayvallet pgayvallet merged commit b606054 into elastic:main Jan 18, 2022
@kibanamachine
Copy link
Contributor

💔 All backports failed

Status Branch Result
8.0 Backport failed because of merge conflicts

How to fix

Re-run the backport manually:

node scripts/backport --pr 120677

Questions ?

Please refer to the Backport tool documentation

pgayvallet added a commit to pgayvallet/kibana that referenced this pull request Jan 18, 2022
…ountered (elastic#120677)

* initial POC

* remove test code

* update the header holding logic

* add new API to plugin context

* introduce the IAuthHeadersStorage interface

* fix some types, mocks and tests

* export types from server entrypoint

* also export error type

* more doc

* update generated doc

* Fix ES service tests

* add tests for createInternalErrorHandler

* fix type in cli_setup

* generated doc

* add tests for configureClient

* add unit tests for custom transport class

* fix handler propagation to initial clients

* lint

* address review comments

(cherry picked from commit b606054)

# Conflicts:
#	src/core/server/http/auth_headers_storage.ts
pgayvallet added a commit to pgayvallet/kibana that referenced this pull request Jan 18, 2022
…ountered (elastic#120677)

* initial POC

* remove test code

* update the header holding logic

* add new API to plugin context

* introduce the IAuthHeadersStorage interface

* fix some types, mocks and tests

* export types from server entrypoint

* also export error type

* more doc

* update generated doc

* Fix ES service tests

* add tests for createInternalErrorHandler

* fix type in cli_setup

* generated doc

* add tests for configureClient

* add unit tests for custom transport class

* fix handler propagation to initial clients

* lint

* address review comments

(cherry picked from commit b606054)

# Conflicts:
#	src/cli_setup/utils.ts
#	src/core/server/elasticsearch/client/cluster_client.ts
#	src/core/server/elasticsearch/client/configure_client.ts
#	src/core/server/elasticsearch/client/errors.ts
#	src/core/server/elasticsearch/elasticsearch_service.ts
#	src/core/server/elasticsearch/index.ts
#	src/core/server/http/auth_headers_storage.ts
#	src/core/server/index.ts
#	src/core/server/server.api.md
@pgayvallet
Copy link
Contributor Author

Tried to backport to 7.17, but the 7.x ES client is still using their custom TransportRequestPromise with additional properties, making it impossible for our custom transport to try/catch and retry the requests, as this would result on returning vanilla promises without the additional abort property.

pgayvallet added a commit that referenced this pull request Jan 18, 2022
…is encountered (#120677) (#123227)

* Add API to refresh authc headers and retry ES request when 401 is encountered (#120677)

* initial POC

* remove test code

* update the header holding logic

* add new API to plugin context

* introduce the IAuthHeadersStorage interface

* fix some types, mocks and tests

* export types from server entrypoint

* also export error type

* more doc

* update generated doc

* Fix ES service tests

* add tests for createInternalErrorHandler

* fix type in cli_setup

* generated doc

* add tests for configureClient

* add unit tests for custom transport class

* fix handler propagation to initial clients

* lint

* address review comments

(cherry picked from commit b606054)

# Conflicts:
#	src/core/server/http/auth_headers_storage.ts

* try to please typescript
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
auto-backport Deprecated - use backport:version if exact versions are needed Feature:elasticsearch release_note:skip Skip the PR/issue when compiling release notes Team:Core Core services & architecture: plugins, logging, config, saved objects, http, ES client, i18n, etc v8.0.0 v8.1.0
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Provide API to refresh token after authc lifecycle
7 participants