Authenticate and wrap HTTP request headers with context supplier#93656
Authenticate and wrap HTTP request headers with context supplier#93656albertzaharovits wants to merge 43 commits intoelastic:mainfrom
Conversation
This commit moves logic related to implementing security features into the core http server implementation.
9eb56cc to
631e507
Compare
631e507 to
d9da947
Compare
| HttpServerTransport.Dispatcher dispatcher, | ||
| BiConsumer<BasicHttpRequest, ThreadContext> dispatcherContext, |
There was a problem hiding this comment.
We can choose to merge the dispatcherContext in the HttpServerTransport.Dispatcher interface, if we opt to go with presented approach where the RestController furnishes the thread context supplier, but it's the http server transport that employs it, because the Security plugin can alter the latter but not the former.
| if (badRequestCause != null) { | ||
| dispatcher.dispatchBadRequest(channel, threadContext, badRequestCause); | ||
| } else { | ||
| populateRequestThreadContext0(restRequest, channel, threadContext); | ||
| dispatcher.dispatchRequest(restRequest, channel, threadContext); |
There was a problem hiding this comment.
This is the code that clears and scopes the thread context for a particular http request to be handled.
This is now extended to also populate the said thread context (with selected HTTP header values).
There was a problem hiding this comment.
Is this happening before or after authentication ?
There was a problem hiding this comment.
In this PR this is happening after authentication (on main it happens before authentication).
The populateRequestThreadContext hook method is used to instante the authentication context, for the authn that happened earlier.
| import java.util.List; | ||
| import java.util.Map; | ||
|
|
||
| public interface BasicHttpRequest { |
There was a problem hiding this comment.
This interface is the common ground between the two request types, with or without a body.
It is now used in the AuthenticationService as well as the AudittingService.
| * @return the {@link RestRequest.Method} used in the REST request | ||
| * @throws IllegalArgumentException if the HTTP method is invalid | ||
| */ | ||
| RestRequest.Method method(); |
There was a problem hiding this comment.
Method doesn't have to be a RestRequest inner enum, and can be moved here, but such a refactoring has a huge diff.
| dispatcherContext.accept(httpRequest, threadContext); | ||
| populateClientCertificate.accept(channel, threadContext); | ||
| RemoteHostHeader.process(channel, threadContext); | ||
| }; |
There was a problem hiding this comment.
The thread context before running the authentication in the channel handler. It reuses that "dispatch" context that's a sort of baseline (contains selected HTTP header values).
| public static void extractClientCertificates(Logger logger, ThreadContext threadContext, TcpChannel tcpChannel) { | ||
| SSLEngine sslEngine = getSSLEngine(tcpChannel); | ||
| extract(logger, threadContext, sslEngine, tcpChannel); | ||
| public static void extractClientCertificates(Logger logger, ThreadContext threadContext, Channel channel) { |
There was a problem hiding this comment.
Extracting the client certificates must work even when a HttpChannel is not available (it only needs the neety channel anyway).
| if (extractClientCertificate) { | ||
| HttpChannel httpChannel = request.getHttpChannel(); | ||
| SSLEngineUtils.extractClientCertificates(logger, threadContext, httpChannel); | ||
| } |
There was a problem hiding this comment.
The Security plugin now incorporates this when it builds the thread context before running the authentication.
| logger.trace("Found secondary authentication {} in REST request [{}]", secondaryAuthentication, request.uri()); | ||
| logger.trace("Found secondary authentication {} in REST request [{}]", secondaryAuthentication, httpRequest.uri()); | ||
| } | ||
| RemoteHostHeader.process(request, threadContext); |
There was a problem hiding this comment.
This has also been moved to the consolidated thread context populator, before running authentication (here it is "after", but moving it "before" allows more audit events to benefit, and aids the transion from RestRequest to BasicHttpRequest in the auditing service).
| handleException(actionType, channel, e); | ||
| } | ||
|
|
||
| public static void handleAuthenticationFailed(RestChannel channel, Exception e) { |
There was a problem hiding this comment.
"Exports" the authn failure response building logic to be used in the channel handler.
| // this throws if a new http request looks to be already authenticated | ||
| if (authentication != null | ||
| && (context.getRequest() instanceof AuthenticationService.AuditableHttpRequest) | ||
| && (((AuthenticationService.AuditableHttpRequest) context.getRequest()).request instanceof HttpRequest == false)) { |
There was a problem hiding this comment.
Right now, HttpRequests are checked for authentication in two places: the original SecurityRestFilter and the new HttpHeadersAuthenticator, so this check here must account for this fact. But it's likely that the final PR drops the SecurityRestFilter.
| // There should never be an existing audit-id when processing a rest request. | ||
| this.requestId = AuditUtil.generateRequestId(threadContext); | ||
| // There might be an existing audit-id (e.g. generated by the rest request) but there might not be (e.g. an internal action) | ||
| this.requestId = AuditUtil.getOrGenerateRequestId(threadContext); |
There was a problem hiding this comment.
Using AuditUtil#getOrGenerateRequestId instead of AuditUtil#generateRequestId is just a temporary measure. Ultimately HttpRequests will only be intercepted once so we can fall back to the stricter generate.
| } | ||
|
|
||
| LogEntryBuilder withRestOrigin(RestRequest request) { | ||
| LogEntryBuilder withRestOrigin(ThreadContext threadContext) { |
There was a problem hiding this comment.
Some audit events already relied on the remote address to be set in the thread context.
This PR adopts that approach for all the audit events, because I think it's easier to adapt the auditing service to work in the channel handler this way.
| public void dispatchBadRequest(RestChannel channel, ThreadContext threadContext, Throwable cause) { | ||
| Exception authenticationException = HttpHeadersAuthenticator.extractAuthenticationException( | ||
| channel.request().getHttpRequest() | ||
| ); | ||
| if (authenticationException != null) { | ||
| SecurityRestFilter.handleAuthenticationFailed(channel, authenticationException); | ||
| } else { | ||
| dispatcher.dispatchBadRequest(channel, threadContext, cause); | ||
| } | ||
| } |
There was a problem hiding this comment.
Unfortunately the dispatcher needs to have the the "bad request" branch overridden, because of the small differences between the 400 errors and authn failure errors.
jakelandis
left a comment
There was a problem hiding this comment.
I'm still working though this review , but here are a couple early comments. Also, it would be helpful to have some high level explaination of how this works. I think I understand most of it ...but the order of execution between netty functions and what is in thread context is hard to follow.
| * server package's rest handling. | ||
| */ | ||
| public interface HttpRequest { | ||
| public interface HttpRequest extends BasicHttpRequest { |
There was a problem hiding this comment.
This relationship is backwards from what I would expect. I would expect an HttpRequestWithoutBody implements HttpRequest (or possibly PartialHttpRequest to contrast with io.netty.handler.codec.http.FullHttpRequest) and the unsuported methods to throw an exception. Then the usages of HttpRequestWithoutBody can be passed around with the explicit expectations that only part of the larger interface is implemented. i.e. don't use anonymous implementations of BasicHttpRequest instead use a concrete HttpRequestWithoutBody.
There was a problem hiding this comment.
Is this a general preference of yours or it's just this particular case?
I think the two options are:
- an interface extends another and adds more methods; in case the base interface is passed around and the code has some specialized logic it can use
instance ofchecks to downcast - have a single rich interface and throw "not implemented"
There was a problem hiding this comment.
FWIW the body needed for auditing authn success (which, again, is one of the things that's not dealt with properly by this PR) so pending other discussions, we might not need a request interface with a request body....
There was a problem hiding this comment.
don't use anonymous implementations of BasicHttpRequest instead use a concrete HttpRequestWithoutBody.
That's separate from the interface conversation, I think. There's going to be some light wrapping needed because the request classes that netty uses are not available to the Security plugin module.
There was a problem hiding this comment.
Is this a general preference of yours or it's just this particular case?
I think general ? ... basically I think the interface should represent the concept and any sub interfaces should be specializations of that concept. (IMO) Here the concept is a http request and not having access to the body the a specialization. I understand the throwing not implemented from many methods is also it's own issue, so this is more subjective / preference than any rule. However, I do think as-is this has some readability / expectation issues. Maybe just changing the names would help ... as-is there no indication to what the difference between a HttpRequest and BasicHttpRequest means...java doc can help but good names > doc. I think i still prefer what I suggested above ...but having something like FullHttpRequest extends PartialHttpRequest makes more readily apparent what to expect from the contract.
There was a problem hiding this comment.
... has some specialized logic it can use instance of checks to downcast
I would strongly advise against a design that necessitates instanceof checks to downcast
|
|
||
| private final TriConsumer<HttpRequest, Channel, ActionListener<Void>> validator; | ||
| private ArrayDeque<HttpObject> pending = new ArrayDeque<>(4); | ||
| private STATE state = STATE.WAITING_TO_START; |
There was a problem hiding this comment.
does this class need to be threadsafe ? i.e. what is the cardinatlity of this class ? is it one instance per HTTP request, or one instance per channel ...if the later are there some garuntees for how many threads can call the channelRead method ?
There was a problem hiding this comment.
It is one instance per channel, given how it is instantiated in Netty4HttpServerTransport, with ch.pipeline().addLast(..., new Validator()).
There's always one thread per channel. This is core to netty's design and cannot be changed (ever).
So, no it's not required to be thread-safe.
There was a problem hiding this comment.
To be pedantic, there are multiple channels per thread, but for a given channel there's always the same single thread that calls the channel handlers.
|
|
||
| import java.util.ArrayDeque; | ||
|
|
||
| public class Netty4HttpHeaderValidator extends ChannelInboundHandlerAdapter { |
There was a problem hiding this comment.
Is it possible to get some javadoc for what this is doing ? I get that it is the hook in point to "validate headers" which can be used to perform authenticatation from the headers ...but all the technical details are beyond my understanding and docs for ChannelInboundHandlerAdapter does not help much. Also, I don't think my review for this class will be worth much ...
There was a problem hiding this comment.
Of course we'll polish this, with javadoc, comments, etc. It's TimB creation from #92220 . It also does slightly more than what we strictly need (closing a connection in case authn fails), and there is a small thing that I need to clarify myself when we forward the request but drop the contents.
All that's to say is that this needs trimmings, and it will be a separate PR for it to review and merge.
I propose we sync to explain how it works/supposed to work before we get to the polishing stage.
| if (badRequestCause != null) { | ||
| dispatcher.dispatchBadRequest(channel, threadContext, badRequestCause); | ||
| } else { | ||
| populateRequestThreadContext0(restRequest, channel, threadContext); | ||
| dispatcher.dispatchRequest(restRequest, channel, threadContext); |
There was a problem hiding this comment.
Is this happening before or after authentication ?
| @@ -28,50 +34,185 @@ | |||
| public interface RestRequestFilter { | |||
There was a problem hiding this comment.
why does this class need to change ?
There was a problem hiding this comment.
Good question!
Short answer is that this filtering, which is needed for auditing, currently works on RestRequest types, but we now need it to work on HTTP request bodies.
And that's because auditing works with HTTP requests, because authentication now has to work with HTTP requests, and the two are very much entangled.
BUT I have a proposal to decouple auditing for authentication success from the actual authentication (which sounds bad, I admit) and this is the main reason: today, when we only support authentication on the "REST" channel (no transport client), and the authentication happens before the request body is available, and we want to audit the request body, we cannot cleanly couple authentication and auditing.
There was a problem hiding this comment.
Basically, it has to do with auditing, which is not properly addressed by this PR anyway (authn success is audited but not the request body), and for which I have a different proposal anyway (that will not require such changes).
| this.closeChannel = closeChannel; | ||
| } | ||
|
|
||
| public boolean shouldCloseChannel() { |
There was a problem hiding this comment.
Is it OK to close the channel for HTTP pipeline requests ? (will that kill the other requests)
There was a problem hiding this comment.
We don't need this functionality. We don't have it today on main and it's not something we plan to ship (confirmed with TimB).
It's added by TimB in #92220 , and I deliberately kept changes to the channel handler implementation to a minimum to ease the rewieving of this for him (but in the process also complicating it for you, sorry...).
| try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { | ||
| populateThreadContext.apply(requestToAuthenticate, channel, threadContext); | ||
| authenticate.accept(requestToAuthenticate, ActionListener.wrap(ignored -> { | ||
| final ThreadContext.StoredContext authenticatedContext = threadContext.newStoredContext(); |
There was a problem hiding this comment.
I think I remember you said that we need to associate this authenticated context with the request since the context will get lost since it may not part of the same call stack... but can you detail abit more why that is ? Isn't both this code (storing the authenticated context) and the code that restores the authenticated context happening on the same eventloop thread ? (also, what thread does the existing (before this PR) authentication happen on -> the eventloop thread?)
Builds on top of #92220 .
Adds authentication to HTTP headers, and packs the authenticated context to be used when executing the URL handler.