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 Dec 20, 2024
1 parent da2703c commit 47d3807
Show file tree
Hide file tree
Showing 16 changed files with 1,376 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 @@ -233,6 +233,7 @@ gomock(
"Authenticator",
"ClientDialer",
"ClientFactory",
"RequestHeadersAuthenticator",
],
library = "//pkg/grpc",
mock_names = {"Authenticator": "MockGRPCAuthenticator"},
Expand Down
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[grpc.RemoteAuthorizerCacheKey](policy.Remote.CacheReplacementPolicy)
if err != nil {
return nil, util.StatusWrap(err, "Cache replacement policy for remote authorization")
}
return grpc.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
13 changes: 13 additions & 0 deletions pkg/grpc/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ go_library(
"peer_transport_credentials_linux.go",
"proto_trace_attributes_extractor.go",
"proxy_dialer.go",
"remote_authenticator.go",
"remote_authorizer.go",
"remote_grpc_request_authenticator.go",
"request_headers_authenticator.go",
"request_metadata_tracing_interceptor.go",
"server.go",
"tls_client_certificate_authenticator.go",
Expand All @@ -36,6 +40,8 @@ go_library(
deps = [
"//pkg/auth",
"//pkg/clock",
"//pkg/digest",
"//pkg/eviction",
"//pkg/jwt",
"//pkg/program",
"//pkg/proto/auth",
Expand Down Expand Up @@ -63,6 +69,7 @@ go_library(
"@org_golang_google_protobuf//encoding/prototext",
"@org_golang_google_protobuf//proto",
"@org_golang_google_protobuf//reflect/protoreflect",
"@org_golang_google_protobuf//types/known/structpb",
"@org_golang_x_sync//semaphore",
] + select({
"@rules_go//go/platform:android": [
Expand Down Expand Up @@ -100,6 +107,9 @@ go_test(
"metadata_forwarding_and_reusing_interceptor_test.go",
"peer_credentials_authenticator_test.go",
"proto_trace_attributes_extractor_test.go",
"remote_authenticator_test.go",
"remote_authorizer_test.go",
"remote_grpc_request_authenticator_test.go",
"request_metadata_tracing_interceptor_test.go",
"tls_client_certificate_authenticator_test.go",
] + select({
Expand All @@ -124,6 +134,8 @@ go_test(
":grpc",
"//internal/mock",
"//pkg/auth",
"//pkg/digest",
"//pkg/eviction",
"//pkg/proto/auth",
"//pkg/proto/configuration/grpc",
"//pkg/testutil",
Expand All @@ -142,6 +154,7 @@ go_test(
"@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",
],
)
28 changes: 28 additions & 0 deletions pkg/grpc/authenticator.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/buildbarn/bb-storage/pkg/auth"
"github.com/buildbarn/bb-storage/pkg/clock"
"github.com/buildbarn/bb-storage/pkg/eviction"
"github.com/buildbarn/bb-storage/pkg/jwt"
"github.com/buildbarn/bb-storage/pkg/program"
configuration "github.com/buildbarn/bb-storage/pkg/proto/configuration/grpc"
Expand Down Expand Up @@ -97,7 +98,34 @@ func NewAuthenticatorFromConfiguration(policy *configuration.AuthenticationPolic
return nil, false, false, util.StatusWrap(err, "Failed to compile peer credentials metadata extraction JMESPath expression")
}
return NewPeerCredentialsAuthenticator(metadataExtractor), true, false, nil
case *configuration.AuthenticationPolicy_Remote:
authenticator, err := NewRequestHeadersAuthenticatorFromConfiguration(policyKind.Remote.Backend, grpcClientFactory)
if err != nil {
return nil, false, false, err
}
return NewRemoteGrpcRequestAuthenticator(authenticator, policyKind.Remote.Headers), false, false, nil
default:
return nil, false, false, status.Error(codes.InvalidArgument, "Configuration did not contain an authentication policy type")
}
}

// NewRequestHeadersAuthenticatorFromConfiguration creates an Authenticator that
// forwards authentication requests to a remote gRPC service. This is a
// convenient way to integrate custom authentication processes.
func NewRequestHeadersAuthenticatorFromConfiguration(configuration *configuration.RemoteAuthenticationPolicy, grpcClientFactory ClientFactory) (RequestHeadersAuthenticator, error) {
grpcClient, err := grpcClientFactory.NewClientFromConfiguration(configuration.Endpoint)
if err != nil {
return nil, util.StatusWrap(err, "Failed to create authenticator RPC client")
}
evictionSet, err := eviction.NewSetFromConfiguration[RemoteAuthenticatorCacheKey](configuration.CacheReplacementPolicy)
if err != nil {
return nil, util.StatusWrap(err, "Cache replacement policy for remote authentication")
}
return NewRemoteAuthenticator(
grpcClient,
configuration.Scope,
clock.SystemClock,
eviction.NewMetricsSet(evictionSet, "remote_authenticator"),
int(configuration.MaximumCacheSize),
), nil
}
181 changes: 181 additions & 0 deletions pkg/grpc/remote_authenticator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package grpc

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

"github.com/buildbarn/bb-storage/pkg/auth"
"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 <-chan struct{}
response remoteAuthResponse
}

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

func (ce *remoteAuthCacheEntry) HasExpired(now time.Time) bool {
select {
case <-ce.ready:
return ce.response.expirationTime.Before(now)
default:
// Ongoing remote requests have not expired by definition.
return false
}
}

// 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) (*auth.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)

a.lock.Lock()
now := a.clock.Now()
entry := a.getAndTouchCacheEntry(requestKey)
if entry != nil && entry.HasExpired(now) {
entry = nil
}
if entry == nil {
// 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.
entry.response = a.authenticateRemotely(ctx, request)
close(responseReady)
} else {
a.lock.Unlock()

// Wait for the remote request to finish.
select {
case <-ctx.Done():
return nil, util.StatusWrapWithCode(ctx.Err(), codes.Unauthenticated, "Context cancelled")
case <-entry.ready:
// Noop
}
}
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 {
ret := remoteAuthResponse{
// The default expirationTime has already passed.
expirationTime: time.Time{},
}

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

// 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 = auth.NewAuthenticationMetadataFromProto(verdict.Allow)
if err != nil {
ret.err = util.StatusWrapWithCode(err, codes.Unauthenticated, "Bad authentication response")
return ret
}
case *auth_pb.AuthenticateResponse_Deny:
ret.err = status.Error(codes.Unauthenticated, verdict.Deny)
return ret
default:
ret.err = status.Error(codes.Unauthenticated, "Invalid authentication verdict")
return ret
}
return ret
}
Loading

0 comments on commit 47d3807

Please sign in to comment.