Skip to content

Commit 67f1466

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 29e7caa commit 67f1466

17 files changed

+1578
-0
lines changed

internal/mock/BUILD.bazel

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ gomock(
2323
out = "auth.go",
2424
interfaces = [
2525
"Authorizer",
26+
"RequestHeadersAuthenticator",
2627
],
2728
library = "//pkg/auth",
2829
mockgen_model_library = "@org_uber_go_mock//mockgen/model",

pkg/auth/BUILD.bazel

+14
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,28 @@ go_library(
77
"authentication_metadata.go",
88
"authorizer.go",
99
"jmespath_expression_authorizer.go",
10+
"remote_authenticator.go",
11+
"remote_authorizer.go",
12+
"request_headers_authenticator.go",
1013
"static_authorizer.go",
1114
],
1215
importpath = "github.com/buildbarn/bb-storage/pkg/auth",
1316
visibility = ["//visibility:public"],
1417
deps = [
18+
"//pkg/clock",
1519
"//pkg/digest",
20+
"//pkg/eviction",
1621
"//pkg/otel",
1722
"//pkg/proto/auth",
1823
"//pkg/util",
1924
"@com_github_jmespath_go_jmespath//:go-jmespath",
2025
"@io_opentelemetry_go_otel//attribute",
26+
"@org_golang_google_grpc//:grpc",
2127
"@org_golang_google_grpc//codes",
2228
"@org_golang_google_grpc//status",
2329
"@org_golang_google_protobuf//encoding/protojson",
2430
"@org_golang_google_protobuf//proto",
31+
"@org_golang_google_protobuf//types/known/structpb",
2532
],
2633
)
2734

@@ -31,21 +38,28 @@ go_test(
3138
"any_authorizer_test.go",
3239
"authentication_metadata_test.go",
3340
"jmespath_expression_authorizer_test.go",
41+
"remote_authenticator_test.go",
42+
"remote_authorizer_test.go",
3443
"static_authorizer_test.go",
3544
],
3645
deps = [
3746
":auth",
3847
"//internal/mock",
3948
"//pkg/digest",
49+
"//pkg/eviction",
4050
"//pkg/proto/auth",
4151
"//pkg/testutil",
4252
"@com_github_jmespath_go_jmespath//:go-jmespath",
4353
"@com_github_stretchr_testify//require",
4454
"@io_opentelemetry_go_otel//attribute",
4555
"@io_opentelemetry_go_proto_otlp//common/v1:common",
56+
"@org_golang_google_grpc//:grpc",
4657
"@org_golang_google_grpc//codes",
4758
"@org_golang_google_grpc//status",
59+
"@org_golang_google_protobuf//proto",
60+
"@org_golang_google_protobuf//types/known/emptypb",
4861
"@org_golang_google_protobuf//types/known/structpb",
62+
"@org_golang_google_protobuf//types/known/timestamppb",
4963
"@org_uber_go_mock//gomock",
5064
],
5165
)

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[auth.RemoteAuthorizerCacheKey](policy.Remote.CacheReplacementPolicy)
67+
if err != nil {
68+
return nil, util.StatusWrap(err, "Cache replacement policy for remote authorization")
69+
}
70+
return auth.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/auth/remote_authenticator.go

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

0 commit comments

Comments
 (0)