Skip to content

Commit 65f6a9e

Browse files
committed
Implement remote authenticator and authorizer
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.
1 parent da2703c commit 65f6a9e

16 files changed

+1371
-0
lines changed

internal/mock/BUILD.bazel

+1
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ gomock(
233233
"Authenticator",
234234
"ClientDialer",
235235
"ClientFactory",
236+
"RequestHeadersAuthenticator",
236237
],
237238
library = "//pkg/grpc",
238239
mock_names = {"Authenticator": "MockGRPCAuthenticator"},

pkg/auth/configuration/BUILD.bazel

+2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ go_library(
77
visibility = ["//visibility:public"],
88
deps = [
99
"//pkg/auth",
10+
"//pkg/clock",
1011
"//pkg/digest",
12+
"//pkg/eviction",
1113
"//pkg/grpc",
1214
"//pkg/proto/configuration/auth",
1315
"//pkg/util",

pkg/auth/configuration/authorizer_factory.go

+18
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package configuration
22

33
import (
44
"github.com/buildbarn/bb-storage/pkg/auth"
5+
"github.com/buildbarn/bb-storage/pkg/clock"
56
"github.com/buildbarn/bb-storage/pkg/digest"
7+
"github.com/buildbarn/bb-storage/pkg/eviction"
68
"github.com/buildbarn/bb-storage/pkg/grpc"
79
pb "github.com/buildbarn/bb-storage/pkg/proto/configuration/auth"
810
"github.com/buildbarn/bb-storage/pkg/util"
@@ -56,6 +58,22 @@ func (f BaseAuthorizerFactory) NewAuthorizerFromConfiguration(config *pb.Authori
5658
return nil, util.StatusWrapWithCode(err, codes.InvalidArgument, "Failed to compile JMESPath expression")
5759
}
5860
return auth.NewJMESPathExpressionAuthorizer(expression), nil
61+
case *pb.AuthorizerConfiguration_Remote:
62+
grpcClient, err := grpcClientFactory.NewClientFromConfiguration(policy.Remote.Endpoint)
63+
if err != nil {
64+
return nil, util.StatusWrap(err, "Failed to create authorizer RPC client")
65+
}
66+
evictionSet, err := eviction.NewSetFromConfiguration[grpc.RemoteAuthorizerCacheKey](policy.Remote.CacheReplacementPolicy)
67+
if err != nil {
68+
return nil, util.StatusWrap(err, "Cache replacement policy for remote authorization")
69+
}
70+
return grpc.NewRemoteAuthorizer(
71+
grpcClient,
72+
policy.Remote.Scope,
73+
clock.SystemClock,
74+
eviction.NewMetricsSet(evictionSet, "remote_authorizer"),
75+
int(policy.Remote.MaximumCacheSize),
76+
), nil
5977
default:
6078
return nil, status.Error(codes.InvalidArgument, "Unknown authorizer configuration")
6179
}

pkg/grpc/BUILD.bazel

+13
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ go_library(
2727
"peer_transport_credentials_linux.go",
2828
"proto_trace_attributes_extractor.go",
2929
"proxy_dialer.go",
30+
"remote_authenticator.go",
31+
"remote_authorizer.go",
32+
"remote_grpc_request_authenticator.go",
33+
"request_headers_authenticator.go",
3034
"request_metadata_tracing_interceptor.go",
3135
"server.go",
3236
"tls_client_certificate_authenticator.go",
@@ -36,6 +40,8 @@ go_library(
3640
deps = [
3741
"//pkg/auth",
3842
"//pkg/clock",
43+
"//pkg/digest",
44+
"//pkg/eviction",
3945
"//pkg/jwt",
4046
"//pkg/program",
4147
"//pkg/proto/auth",
@@ -63,6 +69,7 @@ go_library(
6369
"@org_golang_google_protobuf//encoding/prototext",
6470
"@org_golang_google_protobuf//proto",
6571
"@org_golang_google_protobuf//reflect/protoreflect",
72+
"@org_golang_google_protobuf//types/known/structpb",
6673
"@org_golang_x_sync//semaphore",
6774
] + select({
6875
"@rules_go//go/platform:android": [
@@ -100,6 +107,9 @@ go_test(
100107
"metadata_forwarding_and_reusing_interceptor_test.go",
101108
"peer_credentials_authenticator_test.go",
102109
"proto_trace_attributes_extractor_test.go",
110+
"remote_authenticator_test.go",
111+
"remote_authorizer_test.go",
112+
"remote_grpc_request_authenticator_test.go",
103113
"request_metadata_tracing_interceptor_test.go",
104114
"tls_client_certificate_authenticator_test.go",
105115
] + select({
@@ -124,6 +134,8 @@ go_test(
124134
":grpc",
125135
"//internal/mock",
126136
"//pkg/auth",
137+
"//pkg/digest",
138+
"//pkg/eviction",
127139
"//pkg/proto/auth",
128140
"//pkg/proto/configuration/grpc",
129141
"//pkg/testutil",
@@ -142,6 +154,7 @@ go_test(
142154
"@org_golang_google_protobuf//proto",
143155
"@org_golang_google_protobuf//types/known/emptypb",
144156
"@org_golang_google_protobuf//types/known/structpb",
157+
"@org_golang_google_protobuf//types/known/timestamppb",
145158
"@org_uber_go_mock//gomock",
146159
],
147160
)

pkg/grpc/authenticator.go

+28
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
"github.com/buildbarn/bb-storage/pkg/auth"
88
"github.com/buildbarn/bb-storage/pkg/clock"
9+
"github.com/buildbarn/bb-storage/pkg/eviction"
910
"github.com/buildbarn/bb-storage/pkg/jwt"
1011
"github.com/buildbarn/bb-storage/pkg/program"
1112
configuration "github.com/buildbarn/bb-storage/pkg/proto/configuration/grpc"
@@ -97,7 +98,34 @@ func NewAuthenticatorFromConfiguration(policy *configuration.AuthenticationPolic
9798
return nil, false, false, util.StatusWrap(err, "Failed to compile peer credentials metadata extraction JMESPath expression")
9899
}
99100
return NewPeerCredentialsAuthenticator(metadataExtractor), true, false, nil
101+
case *configuration.AuthenticationPolicy_Remote:
102+
authenticator, err := NewRequestHeadersAuthenticatorFromConfiguration(policyKind.Remote.Backend, grpcClientFactory)
103+
if err != nil {
104+
return nil, false, false, err
105+
}
106+
return NewRemoteGrpcRequestAuthenticator(authenticator, policyKind.Remote.Headers), false, false, nil
100107
default:
101108
return nil, false, false, status.Error(codes.InvalidArgument, "Configuration did not contain an authentication policy type")
102109
}
103110
}
111+
112+
// NewRequestHeadersAuthenticatorFromConfiguration creates an Authenticator that
113+
// forwards authentication requests to a remote gRPC service. This is a
114+
// convenient way to integrate custom authentication processes.
115+
func NewRequestHeadersAuthenticatorFromConfiguration(configuration *configuration.RemoteAuthenticationPolicy, grpcClientFactory ClientFactory) (RequestHeadersAuthenticator, error) {
116+
grpcClient, err := grpcClientFactory.NewClientFromConfiguration(configuration.Endpoint)
117+
if err != nil {
118+
return nil, util.StatusWrap(err, "Failed to create authenticator RPC client")
119+
}
120+
evictionSet, err := eviction.NewSetFromConfiguration[RemoteAuthenticatorCacheKey](configuration.CacheReplacementPolicy)
121+
if err != nil {
122+
return nil, util.StatusWrap(err, "Cache replacement policy for remote authentication")
123+
}
124+
return NewRemoteAuthenticator(
125+
grpcClient,
126+
configuration.Scope,
127+
clock.SystemClock,
128+
eviction.NewMetricsSet(evictionSet, "remote_authenticator"),
129+
int(configuration.MaximumCacheSize),
130+
), nil
131+
}

pkg/grpc/remote_authenticator.go

+179
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package grpc
2+
3+
import (
4+
"context"
5+
"crypto/sha256"
6+
"sync"
7+
"time"
8+
9+
"github.com/buildbarn/bb-storage/pkg/auth"
10+
"github.com/buildbarn/bb-storage/pkg/clock"
11+
"github.com/buildbarn/bb-storage/pkg/eviction"
12+
auth_pb "github.com/buildbarn/bb-storage/pkg/proto/auth"
13+
"github.com/buildbarn/bb-storage/pkg/util"
14+
"google.golang.org/grpc"
15+
"google.golang.org/grpc/codes"
16+
"google.golang.org/grpc/status"
17+
"google.golang.org/protobuf/proto"
18+
"google.golang.org/protobuf/types/known/structpb"
19+
)
20+
21+
type remoteAuthenticator struct {
22+
remoteAuthClient auth_pb.AuthenticationClient
23+
scope *structpb.Value
24+
25+
clock clock.Clock
26+
maximumCacheSize int
27+
28+
lock sync.Mutex
29+
cachedResponses map[RemoteAuthenticatorCacheKey]*remoteAuthCacheEntry
30+
evictionSet eviction.Set[RemoteAuthenticatorCacheKey]
31+
}
32+
33+
type RemoteAuthenticatorCacheKey [sha256.Size]byte
34+
35+
type remoteAuthCacheEntry struct {
36+
ready <-chan struct{}
37+
response remoteAuthResponse
38+
}
39+
40+
type remoteAuthResponse struct {
41+
expirationTime time.Time
42+
authMetadata *auth.AuthenticationMetadata
43+
err error
44+
}
45+
46+
func (ce *remoteAuthCacheEntry) HasExpired(now time.Time) bool {
47+
select {
48+
case <-ce.ready:
49+
return ce.response.expirationTime.Before(now)
50+
default:
51+
// Ongoing remote requests have not expired by definition.
52+
return false
53+
}
54+
}
55+
56+
// NewRemoteAuthenticator creates a new RemoteAuthenticator for incoming
57+
// requests that forwards headers to a remote service for authentication. The
58+
// result from the remote service is cached.
59+
func NewRemoteAuthenticator(
60+
client grpc.ClientConnInterface,
61+
scope *structpb.Value,
62+
clock clock.Clock,
63+
evictionSet eviction.Set[RemoteAuthenticatorCacheKey],
64+
maximumCacheSize int,
65+
) RequestHeadersAuthenticator {
66+
return &remoteAuthenticator{
67+
remoteAuthClient: auth_pb.NewAuthenticationClient(client),
68+
scope: scope,
69+
70+
clock: clock,
71+
maximumCacheSize: maximumCacheSize,
72+
73+
cachedResponses: make(map[RemoteAuthenticatorCacheKey]*remoteAuthCacheEntry),
74+
evictionSet: evictionSet,
75+
}
76+
}
77+
78+
func (a *remoteAuthenticator) Authenticate(ctx context.Context, headers map[string][]string) (*auth.AuthenticationMetadata, error) {
79+
request := &auth_pb.AuthenticateRequest{
80+
RequestMetadata: make(map[string]*auth_pb.AuthenticateRequest_ValueList, len(headers)),
81+
Scope: a.scope,
82+
}
83+
for headerKey, headerValues := range headers {
84+
request.RequestMetadata[headerKey] = &auth_pb.AuthenticateRequest_ValueList{
85+
Value: headerValues,
86+
}
87+
}
88+
requestBytes, err := proto.Marshal(request)
89+
if err != nil {
90+
return nil, util.StatusWrapWithCode(err, codes.Unauthenticated, "Failed to marshal authenticate request")
91+
}
92+
// Hash the request to use as a cache key to both save memory and avoid
93+
// keeping credentials in the memory.
94+
requestKey := sha256.Sum256(requestBytes)
95+
96+
a.lock.Lock()
97+
now := a.clock.Now()
98+
entry := a.getAndTouchCacheEntry(requestKey)
99+
if entry != nil && entry.HasExpired(now) {
100+
entry = nil
101+
}
102+
if entry == nil {
103+
// No valid cache entry available. Deduplicate requests by creating a
104+
// pending cached response.
105+
responseReady := make(chan struct{})
106+
entry = &remoteAuthCacheEntry{
107+
ready: responseReady,
108+
}
109+
a.cachedResponses[requestKey] = entry
110+
a.lock.Unlock()
111+
112+
// Perform the remote authentication request.
113+
entry.response = a.authenticateRemotely(ctx, request)
114+
close(responseReady)
115+
} else {
116+
a.lock.Unlock()
117+
118+
// Wait for the remote request to finish.
119+
select {
120+
case <-ctx.Done():
121+
return nil, util.StatusWrapWithCode(ctx.Err(), codes.Unauthenticated, "Context cancelled")
122+
case <-entry.ready:
123+
// Noop
124+
}
125+
}
126+
return entry.response.authMetadata, entry.response.err
127+
}
128+
129+
func (a *remoteAuthenticator) getAndTouchCacheEntry(requestKey RemoteAuthenticatorCacheKey) *remoteAuthCacheEntry {
130+
if entry, ok := a.cachedResponses[requestKey]; ok {
131+
// Cache contains a matching entry.
132+
a.evictionSet.Touch(requestKey)
133+
return entry
134+
}
135+
136+
// Cache contains no matching entry. Free up space, so that the
137+
// caller may insert a new entry.
138+
for len(a.cachedResponses) >= a.maximumCacheSize {
139+
delete(a.cachedResponses, a.evictionSet.Peek())
140+
a.evictionSet.Remove()
141+
}
142+
a.evictionSet.Insert(requestKey)
143+
return nil
144+
}
145+
146+
func (a *remoteAuthenticator) authenticateRemotely(ctx context.Context, request *auth_pb.AuthenticateRequest) remoteAuthResponse {
147+
ret := remoteAuthResponse{
148+
// The default expirationTime has already passed.
149+
expirationTime: time.Time{},
150+
}
151+
152+
response, err := a.remoteAuthClient.Authenticate(ctx, request)
153+
if err != nil {
154+
ret.err = util.StatusWrapWithCode(err, codes.Unauthenticated, "Remote authentication failed")
155+
return ret
156+
}
157+
158+
// An invalid expiration time indicates that the response should not be cached.
159+
if response.GetCacheExpirationTime().IsValid() {
160+
// Note that the expiration time might still be valid for non-allow verdicts.
161+
ret.expirationTime = response.GetCacheExpirationTime().AsTime()
162+
}
163+
164+
switch verdict := response.GetVerdict().(type) {
165+
case *auth_pb.AuthenticateResponse_Allow:
166+
ret.authMetadata, err = auth.NewAuthenticationMetadataFromProto(verdict.Allow)
167+
if err != nil {
168+
ret.err = util.StatusWrapWithCode(err, codes.Unauthenticated, "Bad authentication response")
169+
return ret
170+
}
171+
case *auth_pb.AuthenticateResponse_Deny:
172+
ret.err = status.Error(codes.Unauthenticated, verdict.Deny)
173+
return ret
174+
default:
175+
ret.err = status.Error(codes.Unauthenticated, "Invalid authentication verdict")
176+
return ret
177+
}
178+
return ret
179+
}

0 commit comments

Comments
 (0)