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

Remote authenticator and authorizer #234

Open
wants to merge 7 commits into
base: master
Choose a base branch
from

Conversation

moroten
Copy link
Contributor

@moroten moroten commented Dec 20, 2024

The authenticate and authorize tasks can now be sent remotely over gRPC to an external service. This way, custom authentication and authorization does not require a modified builds of the Buildbarn components.

To avoid spamming the remote service with calls for every REv2 request and keep the latency low, the verdicts, both allow and deny, are cached for a duration specified in the response from the remote service.

@moroten moroten force-pushed the remote-auth branch 3 times, most recently from 8e43646 to 47d3807 Compare December 20, 2024 14:13
@moroten
Copy link
Contributor Author

moroten commented Jan 15, 2025

@EdSchouten Welcome back from Christmas holiday. I forgot to bring this up for discussion yesterday. Any comments? No hurry, take your time.


// Deny the request, returning a fixed error message back
// to the client.
string deny = 2;
Copy link
Member

Choose a reason for hiding this comment

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

Maybe good to document what kind of status code should be sent back to the client? Should it always be the equivalent of Unauthenticated? Same with AuthorizeResponse.deny.

pkg/proto/auth/auth.proto Show resolved Hide resolved
Comment on lines 101 to 104
if entry != nil && entry.HasExpired(now) {
entry = nil
}
if entry == nil {
Copy link
Member

Choose a reason for hiding this comment

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

if entry == nil || entry.HasExpired(now) {

?

// Wait for the remote request to finish.
select {
case <-ctx.Done():
return nil, util.StatusWrapWithCode(ctx.Err(), codes.Unauthenticated, "Context cancelled")
Copy link
Member

Choose a reason for hiding this comment

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

return nil, util.StatusFromContext(ctx)

// Noop
}
}
return entry.response.authMetadata, entry.response.err
Copy link
Member

Choose a reason for hiding this comment

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

I don't think this is sound. What happens if entry.response.err is of type codes.Canceled? We end up returning a cancelation error belonging to a different client.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Would it be correct to use contect.Background() in the request?

Copy link
Member

@EdSchouten EdSchouten Jan 17, 2025

Choose a reason for hiding this comment

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

That could work, but has the downside that cancelation isn't propagated immediately. My recommendation would be to stick to ctx, but don't coalesce error results of in-flight RPCs. This is also what we do in other parts of Buildbarn (e.g., BlobReplicator implementations)

"google.golang.org/grpc/status"
)

type remoteGrpcRequestAuthenticator struct {
Copy link
Member

Choose a reason for hiding this comment

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

I'd recommend removing Grpc from the name. That it's for gRPC is already implied based on the package name.

ctrl, ctx := gomock.WithContext(context.Background(), t)
md := metadata.New(
map[string]string{
"Authorization": "token",
Copy link
Member

Choose a reason for hiding this comment

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

Doesn't gRPC metadata use lowercase header names?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The Go gRPC library is case insensitive and translates them to lower case.

Copy link
Member

Choose a reason for hiding this comment

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

Ah, gotcha. It looks like metadata.New() does the normalization to lowercase for you, so it's fine.

@@ -0,0 +1,171 @@
package grpc
Copy link
Member

Choose a reason for hiding this comment

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

I understand why you put this code here, though I would argue that pkg/auth/remote_authorizer.go would be a better spot for this. Or if this ends up introducing cycles, add a pkg/auth/remote or something.

The idea is that pkg/auth contains all authorization related code that can be used irrespective of the kind of transport that was used to make the incoming connection. In our case we can use RemoteAuthorizer both with incoming gRPC and HTTP requests.

What we have in pkg/http and pkg/grpc is the authentication related code, which is quite protocol specific (due to it receiving metadata differently and returning different styles of errors).

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 did have a cycle in the beginning of the development, but apparently not any more.


// RequestHeadersAuthenticator can be used to grant or deny access to a server
// based on request headers, typically from an HTTP or gRPC request.
type RequestHeadersAuthenticator interface {
Copy link
Member

Choose a reason for hiding this comment

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

I think this should live in pkg/auth, as it's not specific to any specific transport protocol, right? Just ones that "provide string key/value headers".

Prepare for remote authentication and authorization to avoid future
curcular dependency.
The authenticate and authorize tasks can now be sent remotely over gRPC
to an external service. This way, custom authentication and
authorization does not require a modified builds of the Buildbarn
components.

To avoid spamming the remote service with calls for every REv2 request
and keep the latency low, the verdicts, both allow and deny, are cached
for a duration specified in the response from the remote service.
@moroten
Copy link
Contributor Author

moroten commented Jan 24, 2025

I've now rebased and fixed all the review comments. The changes were done in Add remote auth .proto definitions and Implement remote authenticator and authorizer.

@moroten
Copy link
Contributor Author

moroten commented Jan 29, 2025

@EdSchouten friendly ping. Would you like me to click "Resolve conversation"?

Comment on lines 50 to 66
func (ce *remoteAuthCacheEntry) IsReady() bool {
select {
case <-ce.ready:
return true
default:
return false
}
}

// IsValid returns false if a new remote request should be made.
func (ce *remoteAuthCacheEntry) IsValid(now time.Time) bool {
if ce.response == nil {
// Error response on the remote request, make a new request.
return false
}
return now.Before(ce.response.expirationTime)
}
Copy link
Member

Choose a reason for hiding this comment

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

Maybe move these methods above remoteAuthResponse, so it's more clear what type they belong to?

Also consider making them private, because they are not provided to satisfy a certain interface.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed and created two maps.

// NewRemoteAuthenticator creates a new RemoteAuthenticator for incoming
// requests that forwards headers to a remote service for authentication. The
// result from the remote service is cached.
func NewRemoteAuthenticator(
Copy link
Member

Choose a reason for hiding this comment

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

NewRemoteRequestHeadersAuthenticator() for consistency with the rest of the code base?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

for {
a.lock.Lock()
entry := a.getAndTouchCacheEntry(requestKey)
if entry == nil || (entry.IsReady() && !entry.IsValid(now)) {
Copy link
Member

Choose a reason for hiding this comment

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

Hmmm... I think this approach is slightly hard to reason about, because we're conflating two things:

  • A map of in-flight requests, so that we can dedupe against those.
  • A map of cached responses, so that we can elide the call altogether.

Right now we're all trying to do this at once through a single data structure, but that does mean that our bookkeeping of in-flight requests is also bound to the eviction policy of the eviction.Set.

What are your thoughts on having two separate maps?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Created two maps, much better. Thank you.

"google.golang.org/grpc/status"
)

type remoteRequestAuthenticator struct {
Copy link
Member

Choose a reason for hiding this comment

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

What are your thoughts on calling this requestHeadersAuthenticator as well? Yes, it would be the same name as the underlying interface, but because it's in a different package (auth.RequestHeaderAuthenticator vs. http.NewRequestHeadersAuthenticator()) they would still be sufficiently distinct.

The reason I'm bringing this up is because there isn't really anything "remote" about this type. It would be perfectly fine to have an implementation of auth.RequestHeadersAuthenticator that looks at stuff in a SQLite database.

This also makes me wonder: should we reimplement our JWT auth to be built on top of this interface as well?

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 also makes me wonder: should we reimplement our JWT auth to be built on top of this interface as well?

Is that just to extract a single header into the JWT handler? Maybe I didn't get how you meant.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants