Skip to content

Commit

Permalink
Implement remote authenticator and authorizer
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
moroten committed Jan 24, 2025
1 parent 29e7caa commit 67f1466
Show file tree
Hide file tree
Showing 17 changed files with 1,578 additions and 0 deletions.
1 change: 1 addition & 0 deletions internal/mock/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ gomock(
out = "auth.go",
interfaces = [
"Authorizer",
"RequestHeadersAuthenticator",
],
library = "//pkg/auth",
mockgen_model_library = "@org_uber_go_mock//mockgen/model",
Expand Down
14 changes: 14 additions & 0 deletions pkg/auth/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,28 @@ go_library(
"authentication_metadata.go",
"authorizer.go",
"jmespath_expression_authorizer.go",
"remote_authenticator.go",
"remote_authorizer.go",
"request_headers_authenticator.go",
"static_authorizer.go",
],
importpath = "github.com/buildbarn/bb-storage/pkg/auth",
visibility = ["//visibility:public"],
deps = [
"//pkg/clock",
"//pkg/digest",
"//pkg/eviction",
"//pkg/otel",
"//pkg/proto/auth",
"//pkg/util",
"@com_github_jmespath_go_jmespath//:go-jmespath",
"@io_opentelemetry_go_otel//attribute",
"@org_golang_google_grpc//:grpc",
"@org_golang_google_grpc//codes",
"@org_golang_google_grpc//status",
"@org_golang_google_protobuf//encoding/protojson",
"@org_golang_google_protobuf//proto",
"@org_golang_google_protobuf//types/known/structpb",
],
)

Expand All @@ -31,21 +38,28 @@ go_test(
"any_authorizer_test.go",
"authentication_metadata_test.go",
"jmespath_expression_authorizer_test.go",
"remote_authenticator_test.go",
"remote_authorizer_test.go",
"static_authorizer_test.go",
],
deps = [
":auth",
"//internal/mock",
"//pkg/digest",
"//pkg/eviction",
"//pkg/proto/auth",
"//pkg/testutil",
"@com_github_jmespath_go_jmespath//:go-jmespath",
"@com_github_stretchr_testify//require",
"@io_opentelemetry_go_otel//attribute",
"@io_opentelemetry_go_proto_otlp//common/v1:common",
"@org_golang_google_grpc//:grpc",
"@org_golang_google_grpc//codes",
"@org_golang_google_grpc//status",
"@org_golang_google_protobuf//proto",
"@org_golang_google_protobuf//types/known/emptypb",
"@org_golang_google_protobuf//types/known/structpb",
"@org_golang_google_protobuf//types/known/timestamppb",
"@org_uber_go_mock//gomock",
],
)
2 changes: 2 additions & 0 deletions pkg/auth/configuration/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ go_library(
visibility = ["//visibility:public"],
deps = [
"//pkg/auth",
"//pkg/clock",
"//pkg/digest",
"//pkg/eviction",
"//pkg/grpc",
"//pkg/proto/configuration/auth",
"//pkg/util",
Expand Down
18 changes: 18 additions & 0 deletions pkg/auth/configuration/authorizer_factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package configuration

import (
"github.com/buildbarn/bb-storage/pkg/auth"
"github.com/buildbarn/bb-storage/pkg/clock"
"github.com/buildbarn/bb-storage/pkg/digest"
"github.com/buildbarn/bb-storage/pkg/eviction"
"github.com/buildbarn/bb-storage/pkg/grpc"
pb "github.com/buildbarn/bb-storage/pkg/proto/configuration/auth"
"github.com/buildbarn/bb-storage/pkg/util"
Expand Down Expand Up @@ -56,6 +58,22 @@ func (f BaseAuthorizerFactory) NewAuthorizerFromConfiguration(config *pb.Authori
return nil, util.StatusWrapWithCode(err, codes.InvalidArgument, "Failed to compile JMESPath expression")
}
return auth.NewJMESPathExpressionAuthorizer(expression), nil
case *pb.AuthorizerConfiguration_Remote:
grpcClient, err := grpcClientFactory.NewClientFromConfiguration(policy.Remote.Endpoint)
if err != nil {
return nil, util.StatusWrap(err, "Failed to create authorizer RPC client")
}
evictionSet, err := eviction.NewSetFromConfiguration[auth.RemoteAuthorizerCacheKey](policy.Remote.CacheReplacementPolicy)
if err != nil {
return nil, util.StatusWrap(err, "Cache replacement policy for remote authorization")
}
return auth.NewRemoteAuthorizer(
grpcClient,
policy.Remote.Scope,
clock.SystemClock,
eviction.NewMetricsSet(evictionSet, "remote_authorizer"),
int(policy.Remote.MaximumCacheSize),
), nil
default:
return nil, status.Error(codes.InvalidArgument, "Unknown authorizer configuration")
}
Expand Down
196 changes: 196 additions & 0 deletions pkg/auth/remote_authenticator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package auth

import (
"context"
"crypto/sha256"
"sync"
"time"

"github.com/buildbarn/bb-storage/pkg/clock"
"github.com/buildbarn/bb-storage/pkg/eviction"
auth_pb "github.com/buildbarn/bb-storage/pkg/proto/auth"
"github.com/buildbarn/bb-storage/pkg/util"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/structpb"
)

type remoteAuthenticator struct {
remoteAuthClient auth_pb.AuthenticationClient
scope *structpb.Value

clock clock.Clock
maximumCacheSize int

lock sync.Mutex
cachedResponses map[RemoteAuthenticatorCacheKey]*remoteAuthCacheEntry
evictionSet eviction.Set[RemoteAuthenticatorCacheKey]
}

// RemoteAuthenticatorCacheKey is the key type for the cache inside
// remoteAuthenticator.
type RemoteAuthenticatorCacheKey [sha256.Size]byte

type remoteAuthCacheEntry struct {
// ready is closed when the remote request has finished.
ready <-chan struct{}
// response is nil if the request is ongoing or has failed and should be
// retried.
response *remoteAuthResponse
}

type remoteAuthResponse struct {
expirationTime time.Time
authMetadata *AuthenticationMetadata
err error
}

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)
}

// 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(
client grpc.ClientConnInterface,
scope *structpb.Value,
clock clock.Clock,
evictionSet eviction.Set[RemoteAuthenticatorCacheKey],
maximumCacheSize int,
) RequestHeadersAuthenticator {
return &remoteAuthenticator{
remoteAuthClient: auth_pb.NewAuthenticationClient(client),
scope: scope,

clock: clock,
maximumCacheSize: maximumCacheSize,

cachedResponses: make(map[RemoteAuthenticatorCacheKey]*remoteAuthCacheEntry),
evictionSet: evictionSet,
}
}

func (a *remoteAuthenticator) Authenticate(ctx context.Context, headers map[string][]string) (*AuthenticationMetadata, error) {
request := &auth_pb.AuthenticateRequest{
RequestMetadata: make(map[string]*auth_pb.AuthenticateRequest_ValueList, len(headers)),
Scope: a.scope,
}
for headerKey, headerValues := range headers {
request.RequestMetadata[headerKey] = &auth_pb.AuthenticateRequest_ValueList{
Value: headerValues,
}
}
requestBytes, err := proto.Marshal(request)
if err != nil {
return nil, util.StatusWrapWithCode(err, codes.Unauthenticated, "Failed to marshal authenticate request")
}
// Hash the request to use as a cache key to both save memory and avoid
// keeping credentials in the memory.
requestKey := sha256.Sum256(requestBytes)

now := a.clock.Now()
for {
a.lock.Lock()
entry := a.getAndTouchCacheEntry(requestKey)
if entry == nil || (entry.IsReady() && !entry.IsValid(now)) {
// No valid cache entry available. Deduplicate requests by creating a
// pending cached response.
responseReady := make(chan struct{})
entry = &remoteAuthCacheEntry{
ready: responseReady,
}
a.cachedResponses[requestKey] = entry
a.lock.Unlock()

// Perform the remote authentication request.
response, err := a.authenticateRemotely(ctx, request)
if err != nil {
close(responseReady)
return nil, err
}
entry.response = response
close(responseReady)
return response.authMetadata, response.err
}
a.lock.Unlock()

// Wait for the remote request to finish.
select {
case <-ctx.Done():
return nil, util.StatusFromContext(ctx)
case <-entry.ready:
// Check whether the remote authentication call succeeded.
// Otherwise, retry with our own ctx.
if entry.response != nil {
// Note that the expiration time is not checked, as the response
// is as fresh as it can be.
return entry.response.authMetadata, entry.response.err
}
}
}
}

func (a *remoteAuthenticator) getAndTouchCacheEntry(requestKey RemoteAuthenticatorCacheKey) *remoteAuthCacheEntry {
if entry, ok := a.cachedResponses[requestKey]; ok {
// Cache contains a matching entry.
a.evictionSet.Touch(requestKey)
return entry
}

// Cache contains no matching entry. Free up space, so that the
// caller may insert a new entry.
for len(a.cachedResponses) >= a.maximumCacheSize {
delete(a.cachedResponses, a.evictionSet.Peek())
a.evictionSet.Remove()
}
a.evictionSet.Insert(requestKey)
return nil
}

func (a *remoteAuthenticator) authenticateRemotely(ctx context.Context, request *auth_pb.AuthenticateRequest) (*remoteAuthResponse, error) {
ret := remoteAuthResponse{
// The default expirationTime has already passed.
expirationTime: time.Time{},
}

response, err := a.remoteAuthClient.Authenticate(ctx, request)
if err != nil {
return nil, util.StatusWrapWithCode(err, codes.Unauthenticated, "Remote authentication failed")
}

// An invalid expiration time indicates that the response should not be cached.
if response.GetCacheExpirationTime().IsValid() {
// Note that the expiration time might still be valid for non-allow verdicts.
ret.expirationTime = response.GetCacheExpirationTime().AsTime()
}

switch verdict := response.GetVerdict().(type) {
case *auth_pb.AuthenticateResponse_Allow:
ret.authMetadata, err = NewAuthenticationMetadataFromProto(verdict.Allow)
if err != nil {
ret.err = util.StatusWrapWithCode(err, codes.Unauthenticated, "Bad authentication response")
}
case *auth_pb.AuthenticateResponse_Deny:
ret.err = status.Error(codes.Unauthenticated, verdict.Deny)
default:
ret.err = status.Error(codes.Unauthenticated, "Invalid authentication verdict")
}
return &ret, nil
}
Loading

0 comments on commit 67f1466

Please sign in to comment.