Repository: buchgr/bazel-remote
Pull request #412 (2021-03-22 23:25:23)

[auth] add config setting to allow unauthenticated reads
If authentication is enabled, this new flag allows readonly access for unauthenticated clients. Technically, some of the
"readonly" API calls do modify the cache (by affecting the LRU index, or by causing blobs to be downloaded from proxy
backends), but they do not add or modify ActionCache blobs so cannot inject new data into the cache.

Implements #381.


Patch:
diff --git a/.bazelci/presubmit.yml b/.bazelci/presubmit.yml
index fac51df..3e74755 100644
--- a/.bazelci/presubmit.yml
+++ b/.bazelci/presubmit.yml
@@ -55,6 +55,12 @@ tasks:
     shell_commands:
       - "echo +++ Run TLS tests"
       - ".bazelci/tls-tests.sh"
+  basic_auth_tests:
+    platform: ubuntu2004
+    name: "basic auth tests"
+    shell_commands:
+      - "echo +++ Run basic auth tests"
+      - ".bazelci/basic-auth-tests.sh"
   migration_tests:
     platform: ubuntu2004
     name: "migration tests"
diff --git a/README.md b/README.md
index 493637c..340e211 100644
--- a/README.md
+++ b/README.md
@@ -183,6 +183,11 @@ OPTIONS:
    --tls_key_file value Path to a pem encoded key file.
       [$BAZEL_REMOTE_TLS_KEY_FILE]
 
+   --allow_unauthenticated_reads If authentication is enabled
+      (--htpasswd_file or --tls_ca_file), allow unauthenticated clients read
+      access. (default: false, ie if authentication is required, read-only
+      requests must also be authenticated) [$BAZEL_REMOTE_UNAUTHENTICATED_READS]
+
    --idle_timeout value The maximum period of having received no request
       after which the server will shut itself down. (default: 0s, ie disabled)
       [$BAZEL_REMOTE_IDLE_TIMEOUT]
@@ -299,6 +304,10 @@ host: localhost
 # Alternatively, you can use simple authentication:
 #htpasswd_file: path/to/.htpasswd
 
+# If tls_ca_file or htpasswd_file are specified, you can choose
+# whether or not to allow unauthenticated read access:
+#allow_unauthenticated_reads: false
+
 # If specified, bazel-remote should exit after being idle
 # for this long. Time units can be one of: "s", "m", "h".
 #idle_timeout: 45s
diff --git a/config/config.go b/config/config.go
index 4865c92..e241551 100644
--- a/config/config.go
+++ b/config/config.go
@@ -55,6 +55,7 @@ type Config struct {
 	TLSCaFile                   string                    `yaml:"tls_ca_file"`
 	TLSCertFile                 string                    `yaml:"tls_cert_file"`
 	TLSKeyFile                  string                    `yaml:"tls_key_file"`
+	AllowUnauthenticatedReads   bool                      `yaml:"allow_unauthenticated_reads"`
 	S3CloudStorage              *S3CloudStorageConfig     `yaml:"s3_proxy,omitempty"`
 	GoogleCloudStorage          *GoogleCloudStorageConfig `yaml:"gcs_proxy,omitempty"`
 	HTTPBackend                 *HTTPBackendConfig        `yaml:"http_proxy,omitempty"`
@@ -88,6 +89,7 @@ func newFromArgs(dir string, maxSize int, storageMode string,
 	tlsCaFile string,
 	tlsCertFile string,
 	tlsKeyFile string,
+	allowUnauthenticatedReads bool,
 	idleTimeout time.Duration,
 	hc *HTTPBackendConfig,
 	gcs *GoogleCloudStorageConfig,
@@ -115,6 +117,7 @@ func newFromArgs(dir string, maxSize int, storageMode string,
 		TLSCaFile:                   tlsCaFile,
 		TLSCertFile:                 tlsCertFile,
 		TLSKeyFile:                  tlsKeyFile,
+		AllowUnauthenticatedReads:   allowUnauthenticatedReads,
 		S3CloudStorage:              s3,
 		GoogleCloudStorage:          gcs,
 		HTTPBackend:                 hc,
@@ -230,6 +233,10 @@ func validateConfig(c *Config) error {
 			"and 'tls_cert_file' specified.")
 	}
 
+	if c.AllowUnauthenticatedReads && c.TLSCaFile == "" && c.HtpasswdFile == "" {
+		return errors.New("AllowUnauthenticatedReads setting is only available when authentication is enabled")
+	}
+
 	if c.GoogleCloudStorage != nil && c.HTTPBackend != nil && c.S3CloudStorage != nil {
 		return errors.New("One can specify at most one proxying backend")
 	}
@@ -344,6 +351,7 @@ func get(ctx *cli.Context) (*Config, error) {
 		ctx.String("tls_ca_file"),
 		ctx.String("tls_cert_file"),
 		ctx.String("tls_key_file"),
+		ctx.Bool("allow_unauthenticated_reads"),
 		ctx.Duration("idle_timeout"),
 		hc,
 		gcs,
diff --git a/config/tls.go b/config/tls.go
index 51a50cb..699e892 100644
--- a/config/tls.go
+++ b/config/tls.go
@@ -27,10 +27,19 @@ func (c *Config) setTLSConfig() error {
 			return fmt.Errorf("Error reading certificate/key pair: %w", err)
 		}
 
+		var cat tls.ClientAuthType = tls.RequireAndVerifyClientCert
+		if c.AllowUnauthenticatedReads {
+			// This allows us to handle some requests without a valid client
+			// certificate, but then we need to explicitly check for verified
+			// certs on requests that we require auth for.
+			// See server.checkGRPCClientCert and httpCache.hasValidClientCert.
+			cat = tls.VerifyClientCertIfGiven
+		}
+
 		c.TLSConfig = &tls.Config{
 			Certificates: []tls.Certificate{readCert},
 			ClientCAs:    caCertPool,
-			ClientAuth:   tls.RequireAndVerifyClientCert,
+			ClientAuth:   cat,
 		}
 
 		return nil
diff --git a/main.go b/main.go
index 6d22898..098c209 100644
--- a/main.go
+++ b/main.go
@@ -105,13 +105,31 @@ func run(ctx *cli.Context) error {
 	}
 
 	validateAC := !c.DisableHTTPACValidation
-	h := server.NewHTTPCache(diskCache, accessLogger, errorLogger, validateAC, c.EnableACKeyInstanceMangling, gitCommit)
+	h := server.NewHTTPCache(diskCache, accessLogger, errorLogger, validateAC,
+		c.EnableACKeyInstanceMangling, c.AllowUnauthenticatedReads, gitCommit)
 
 	var htpasswdSecrets auth.SecretProvider
+	authMode := "disabled"
 	cacheHandler := h.CacheHandler
 	if c.HtpasswdFile != "" {
+		authMode = "basic"
 		htpasswdSecrets = auth.HtpasswdFileProvider(c.HtpasswdFile)
-		cacheHandler = wrapAuthHandler(cacheHandler, htpasswdSecrets, c.Host)
+		if c.AllowUnauthenticatedReads {
+			cacheHandler = unauthenticatedReadWrapper(cacheHandler, htpasswdSecrets, c.Host)
+		} else {
+			cacheHandler = authWrapper(cacheHandler, htpasswdSecrets, c.Host)
+		}
+	} else if c.TLSCaFile != "" {
+		authMode = "mTLS"
+	}
+	log.Println("Authentication:", authMode)
+
+	if authMode != "disabled" {
+		if c.AllowUnauthenticatedReads {
+			log.Println("Access mode: authentication required for writes, unauthenticated reads allowed")
+		} else {
+			log.Println("Access mode: authentication required")
+		}
 	}
 
 	var idleTimer *idle.Timer
@@ -166,7 +184,7 @@ func run(ctx *cli.Context) error {
 			}
 
 			if htpasswdSecrets != nil {
-				gba := server.NewGrpcBasicAuth(htpasswdSecrets)
+				gba := server.NewGrpcBasicAuth(htpasswdSecrets, c.AllowUnauthenticatedReads)
 				streamInterceptors = append(streamInterceptors, gba.StreamServerInterceptor)
 				unaryInterceptors = append(unaryInterceptors, gba.UnaryServerInterceptor)
 			}
@@ -196,10 +214,13 @@ func run(ctx *cli.Context) error {
 			}
 			log.Println("experimental gRPC remote asset API:", remoteAssetStatus)
 
+			checkClientCertForWrites := c.AllowUnauthenticatedReads && c.TLSCaFile != ""
+
 			err3 := server.ListenAndServeGRPC(addr, opts,
 				validateAC,
 				c.EnableACKeyInstanceMangling,
 				enableRemoteAssetAPI,
+				checkClientCertForWrites,
 				diskCache, accessLogger, errorLogger)
 			if err3 != nil {
 				log.Fatal(err3)
@@ -256,7 +277,26 @@ func wrapIdleHandler(handler http.HandlerFunc, idleTimer *idle.Timer, accessLogg
 	})
 }
 
-func wrapAuthHandler(handler http.HandlerFunc, secrets auth.SecretProvider, host string) http.HandlerFunc {
+// A http.HandlerFunc wrapper which requires successful basic
+// authentication for all requests.
+func authWrapper(handler http.HandlerFunc, secrets auth.SecretProvider, host string) http.HandlerFunc {
 	authenticator := auth.NewBasicAuthenticator(host, secrets)
 	return auth.JustCheck(authenticator, handler)
 }
+
+// A http.HandlerFunc wrapper which requires successful basic
+// authentication for write requests, but allows unauthenticated
+// read requests.
+func unauthenticatedReadWrapper(handler http.HandlerFunc, secrets auth.SecretProvider, host string) http.HandlerFunc {
+	authenticator := auth.NewBasicAuthenticator(host, secrets)
+	authHandler := auth.JustCheck(authenticator, handler)
+
+	return func(w http.ResponseWriter, r *http.Request) {
+		if r.Method == http.MethodGet || r.Method == http.MethodHead {
+			handler(w, r)
+			return
+		}
+
+		authHandler(w, r)
+	}
+}
diff --git a/server/BUILD.bazel b/server/BUILD.bazel
index b27555f..1b914b5 100644
--- a/server/BUILD.bazel
+++ b/server/BUILD.bazel
@@ -35,6 +35,7 @@ go_library(
         "@go_googleapis//google/rpc:status_go_proto",
         "@org_golang_google_grpc//:go_default_library",
         "@org_golang_google_grpc//codes:go_default_library",
+        "@org_golang_google_grpc//credentials:go_default_library",
         "@org_golang_google_grpc//encoding/gzip:go_default_library",
         "@org_golang_google_grpc//metadata:go_default_library",
         "@org_golang_google_grpc//peer:go_default_library",
diff --git a/server/grpc.go b/server/grpc.go
index 035f7bb..c8bdd06 100644
--- a/server/grpc.go
+++ b/server/grpc.go
@@ -9,7 +9,9 @@ import (
 	"google.golang.org/genproto/googleapis/bytestream"
 	"google.golang.org/grpc"
 	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/credentials"
 	_ "google.golang.org/grpc/encoding/gzip" // Register gzip support.
+	"google.golang.org/grpc/peer"
 	"google.golang.org/grpc/status"
 
 	asset "github.com/buchgr/bazel-remote/genproto/build/bazel/remote/asset/v1"
@@ -34,11 +36,12 @@ var (
 )
 
 type grpcServer struct {
-	cache        *disk.Cache
-	accessLogger cache.Logger
-	errorLogger  cache.Logger
-	depsCheck    bool
-	mangleACKeys bool
+	cache                    *disk.Cache
+	accessLogger             cache.Logger
+	errorLogger              cache.Logger
+	depsCheck                bool
+	mangleACKeys             bool
+	checkClientCertForWrites bool
 }
 
 // ListenAndServeGRPC creates a new gRPC server and listens on the given
@@ -48,6 +51,7 @@ func ListenAndServeGRPC(addr string, opts []grpc.ServerOption,
 	validateACDeps bool,
 	mangleACKeys bool,
 	enableRemoteAssetAPI bool,
+	checkClientCertForWrites bool,
 	c *disk.Cache, a cache.Logger, e cache.Logger) error {
 
 	listener, err := net.Listen("tcp", addr)
@@ -55,20 +59,22 @@ func ListenAndServeGRPC(addr string, opts []grpc.ServerOption,
 		return err
 	}
 
-	return serveGRPC(listener, opts, validateACDeps, mangleACKeys, enableRemoteAssetAPI, c, a, e)
+	return serveGRPC(listener, opts, validateACDeps, mangleACKeys, enableRemoteAssetAPI, checkClientCertForWrites, c, a, e)
 }
 
 func serveGRPC(l net.Listener, opts []grpc.ServerOption,
 	validateACDepsCheck bool,
 	mangleACKeys bool,
 	enableRemoteAssetAPI bool,
+	checkClientCertForWrites bool,
 	c *disk.Cache, a cache.Logger, e cache.Logger) error {
 
 	srv := grpc.NewServer(opts...)
 	s := &grpcServer{
 		cache: c, accessLogger: a, errorLogger: e,
-		depsCheck:    validateACDepsCheck,
-		mangleACKeys: mangleACKeys,
+		depsCheck:                validateACDepsCheck,
+		mangleACKeys:             mangleACKeys,
+		checkClientCertForWrites: checkClientCertForWrites,
 	}
 	pb.RegisterActionCacheServer(srv, s)
 	pb.RegisterCapabilitiesServer(srv, s)
@@ -140,3 +146,27 @@ func (s *grpcServer) validateHash(hash string, size int64, logPrefix string) err
 
 	return nil
 }
+
+// Return a non-nil grpc error if a valid client certificate can't be
+// extracted from ctx.
+//
+// This is only used when mutual TLS authentication and unauthenticated
+// reads are enabled.
+func checkGRPCClientCert(ctx context.Context) error {
+
+	p, ok := peer.FromContext(ctx)
+	if !ok {
+		return status.Error(codes.Unauthenticated, "no peer found")
+	}
+
+	tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo)
+	if !ok {
+		return status.Error(codes.Unauthenticated, "unrecognised peer transport credentials")
+	}
+
+	if len(tlsInfo.State.VerifiedChains) == 0 || len(tlsInfo.State.VerifiedChains[0]) == 0 {
+		return status.Error(codes.Unauthenticated, "could not verify peer certificate")
+	}
+
+	return nil
+}
diff --git a/server/grpc_ac.go b/server/grpc_ac.go
index 518c25a..532e8c2 100644
--- a/server/grpc_ac.go
+++ b/server/grpc_ac.go
@@ -194,6 +194,13 @@ func (s *grpcServer) maybeInline(inline bool, slice *[]byte, digest **pb.Digest,
 func (s *grpcServer) UpdateActionResult(ctx context.Context,
 	req *pb.UpdateActionResultRequest) (*pb.ActionResult, error) {
 
+	if s.checkClientCertForWrites {
+		err := checkGRPCClientCert(ctx)
+		if err != nil {
+			return nil, err
+		}
+	}
+
 	logPrefix := "GRPC AC PUT"
 	err := s.validateHash(req.ActionDigest.Hash, req.ActionDigest.SizeBytes, logPrefix)
 	if err != nil {
diff --git a/server/grpc_asset.go b/server/grpc_asset.go
index d344fbc..88e1038 100644
--- a/server/grpc_asset.go
+++ b/server/grpc_asset.go
@@ -92,7 +92,18 @@ func (s *grpcServer) FetchBlob(ctx context.Context, req *asset.FetchBlobRequest)
 		}
 	}
 
-	// Cache miss. See if we can download one of the URIs.
+	// Cache miss.
+
+	if s.checkClientCertForWrites {
+		err := checkGRPCClientCert(ctx)
+		if err != nil {
+			return &asset.FetchBlobResponse{
+				Status: &status.Status{Code: int32(codes.NotFound)},
+			}, nil
+		}
+	}
+
+	// See if we can download one of the URIs.
 
 	for _, uri := range req.GetUris() {
 		ok, actualHash, size := s.fetchItem(uri, sha256Str)
diff --git a/server/grpc_basic_auth.go b/server/grpc_basic_auth.go
index 429e594..1717914 100644
--- a/server/grpc_basic_auth.go
+++ b/server/grpc_basic_auth.go
@@ -21,21 +21,42 @@ var (
 		"access denied")
 )
 
+var readOnlyMethods = map[string]struct{}{
+	"/build.bazel.remote.execution.v2.ActionCache/GetActionResult":                {},
+	"/build.bazel.remote.execution.v2.ContentAddressableStorage/FindMissingBlobs": {},
+	"/build.bazel.remote.execution.v2.ContentAddressableStorage/BatchReadBlobs":   {},
+	"/build.bazel.remote.execution.v2.ContentAddressableStorage/GetTree":          {},
+	"/build.bazel.remote.execution.v2.Capabilities/GetCapabilities":               {},
+	"/google.bytestream.ByteStream/Read":                                          {},
+}
+
 // GrpcBasicAuth wraps an auth.SecretProvider, and provides gRPC interceptors
 // that verify that requests can be authenticated using HTTP basic auth.
 type GrpcBasicAuth struct {
-	secrets auth.SecretProvider
+	secrets                      auth.SecretProvider
+	allowUnauthenticatedReadOnly bool
 }
 
 // NewGrpcBasicAuth returns a GrpcBasicAuth that wraps the given
 // auth.SecretProvider.
-func NewGrpcBasicAuth(secrets auth.SecretProvider) *GrpcBasicAuth {
-	return &GrpcBasicAuth{secrets: secrets}
+func NewGrpcBasicAuth(secrets auth.SecretProvider, allowUnauthenticatedReadOnly bool) *GrpcBasicAuth {
+	return &GrpcBasicAuth{
+		secrets:                      secrets,
+		allowUnauthenticatedReadOnly: allowUnauthenticatedReadOnly,
+	}
 }
 
 // StreamServerInterceptor returns a streaming server interceptor that
 // verifies that each request can be authenticated using HTTP basic auth.
 func (b *GrpcBasicAuth) StreamServerInterceptor(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
+
+	if b.allowUnauthenticatedReadOnly {
+		_, ro := readOnlyMethods[info.FullMethod]
+		if ro {
+			return handler(srv, ss)
+		}
+	}
+
 	username, password, err := getLogin(ss.Context())
 	if err != nil {
 		return err
@@ -54,6 +75,14 @@ func (b *GrpcBasicAuth) StreamServerInterceptor(srv interface{}, ss grpc.ServerS
 // UnaryServerInterceptor returns a unary server interceptor that verifies
 // that each request can be authenticated using HTTP basic auth.
 func (b *GrpcBasicAuth) UnaryServerInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
+
+	if b.allowUnauthenticatedReadOnly {
+		_, ro := readOnlyMethods[info.FullMethod]
+		if ro {
+			return handler(ctx, req)
+		}
+	}
+
 	username, password, err := getLogin(ctx)
 	if err != nil {
 		return nil, err
diff --git a/server/grpc_cas.go b/server/grpc_cas.go
index 6f87b2e..cde9ce4 100644
--- a/server/grpc_cas.go
+++ b/server/grpc_cas.go
@@ -52,6 +52,13 @@ func (s *grpcServer) FindMissingBlobs(ctx context.Context,
 func (s *grpcServer) BatchUpdateBlobs(ctx context.Context,
 	in *pb.BatchUpdateBlobsRequest) (*pb.BatchUpdateBlobsResponse, error) {
 
+	if s.checkClientCertForWrites {
+		err := checkGRPCClientCert(ctx)
+		if err != nil {
+			return nil, err
+		}
+	}
+
 	resp := pb.BatchUpdateBlobsResponse{
 		Responses: make([]*pb.BatchUpdateBlobsResponse_Response,
 			0, len(in.Requests)),
diff --git a/server/http.go b/server/http.go
index fdf248a..a5634cc 100644
--- a/server/http.go
+++ b/server/http.go
@@ -38,12 +38,13 @@ type HTTPCache interface {
 }
 
 type httpCache struct {
-	cache        *disk.Cache
-	accessLogger cache.Logger
-	errorLogger  cache.Logger
-	validateAC   bool
-	mangleACKeys bool
-	gitCommit    string
+	cache                    *disk.Cache
+	accessLogger             cache.Logger
+	errorLogger              cache.Logger
+	validateAC               bool
+	mangleACKeys             bool
+	gitCommit                string
+	checkClientCertForWrites bool
 }
 
 type statusPageData struct {
@@ -61,18 +62,19 @@ type statusPageData struct {
 // accessLogger will print one line for each HTTP request to stdout.
 // errorLogger will print unexpected server errors. Inexistent files and malformed URLs will not
 // be reported.
-func NewHTTPCache(cache *disk.Cache, accessLogger cache.Logger, errorLogger cache.Logger, validateAC bool, mangleACKeys bool, commit string) HTTPCache {
+func NewHTTPCache(cache *disk.Cache, accessLogger cache.Logger, errorLogger cache.Logger, validateAC bool, mangleACKeys bool, checkClientCertForWrites bool, commit string) HTTPCache {
 
 	_, _, numItems, _ := cache.Stats()
 
 	errorLogger.Printf("Loaded %d existing disk cache items.", numItems)
 
 	hc := &httpCache{
-		cache:        cache,
-		accessLogger: accessLogger,
-		errorLogger:  errorLogger,
-		validateAC:   validateAC,
-		mangleACKeys: mangleACKeys,
+		cache:                    cache,
+		accessLogger:             accessLogger,
+		errorLogger:              errorLogger,
+		validateAC:               validateAC,
+		mangleACKeys:             mangleACKeys,
+		checkClientCertForWrites: checkClientCertForWrites,
 	}
 
 	if commit != "{STABLE_GIT_COMMIT}" {
@@ -254,6 +256,10 @@ func (h *httpCache) CacheHandler(w http.ResponseWriter, r *http.Request) {
 		h.logResponse(http.StatusOK, r)
 
 	case http.MethodPut:
+		if h.checkClientCertForWrites && !h.hasValidClientCert(w, r) {
+			return
+		}
+
 		contentLength := r.ContentLength
 
 		// If custom header X-Digest-SizeBytes is set, use that for the
@@ -470,3 +476,31 @@ func (h *httpCache) StatusPageHandler(w http.ResponseWriter, r *http.Request) {
 func path(kind cache.EntryKind, hash string) string {
 	return fmt.Sprintf("/%s/%s", kind, hash)
 }
+
+// If the http.Request is authenticated with a valid client certificate
+// then do nothing and return true. Otherwise, write an error to the
+// http.ResponseWriter, log the error and return false.
+//
+// This is only used when mutual TLS authentication and unauthenticated
+// reads are enabled.
+func (h *httpCache) hasValidClientCert(w http.ResponseWriter, r *http.Request) bool {
+	if r == nil {
+		http.Error(w, "invalid request", http.StatusBadRequest)
+		h.logResponse(http.StatusBadRequest, r)
+		return false
+	}
+
+	if r.TLS == nil {
+		http.Error(w, "missing TLS connection info", http.StatusUnauthorized)
+		h.logResponse(http.StatusUnauthorized, r)
+		return false
+	}
+
+	if len(r.TLS.VerifiedChains) == 0 || len(r.TLS.VerifiedChains[0]) == 0 {
+		http.Error(w, "no valid client certificate", http.StatusUnauthorized)
+		h.logResponse(http.StatusUnauthorized, r)
+		return false
+	}
+
+	return true
+}
diff --git a/utils/flags/flags.go b/utils/flags/flags.go
index 4bb3433..768f915 100644
--- a/utils/flags/flags.go
+++ b/utils/flags/flags.go
@@ -101,6 +101,13 @@ func GetCliFlags() []cli.Flag {
 			Usage:   "Path to a pem encoded key file.",
 			EnvVars: []string{"BAZEL_REMOTE_TLS_KEY_FILE"},
 		},
+		&cli.BoolFlag{
+			Name:        "allow_unauthenticated_reads",
+			Value:       false,
+			Usage:       "If authentication is enabled (--htpasswd_file or --tls_ca_file), allow unauthenticated clients read access.",
+			DefaultText: "false, ie if authentication is required, read-only requests must also be authenticated",
+			EnvVars:     []string{"BAZEL_REMOTE_UNAUTHENTICATED_READS"},
+		},
 		&cli.DurationFlag{
 			Name:        "idle_timeout",
 			Value:       0,
diff --git a/utils/grpcreadclient/BUILD.bazel b/utils/grpcreadclient/BUILD.bazel
new file mode 100644
index 0000000..b92b33a
--- /dev/null
+++ b/utils/grpcreadclient/BUILD.bazel
@@ -0,0 +1,23 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = ["grpcreadclient.go"],
+    importpath = "github.com/buchgr/bazel-remote/utils/grpcreadclient",
+    visibility = ["//visibility:private"],
+    deps = [
+        "//genproto/build/bazel/remote/execution/v2:go_default_library",
+        "@com_github_google_uuid//:go_default_library",
+        "@go_googleapis//google/bytestream:bytestream_go_proto",
+        "@org_golang_google_grpc//:go_default_library",
+        "@org_golang_google_grpc//codes:go_default_library",
+        "@org_golang_google_grpc//credentials:go_default_library",
+        "@org_golang_google_grpc//status:go_default_library",
+    ],
+)
+
+go_binary(
+    name = "grpcreadclient",
+    embed = [":go_default_library"],
+    visibility = ["//visibility:public"],
+)
diff --git a/utils/grpcreadclient/grpcreadclient.go b/utils/grpcreadclient/grpcreadclient.go
new file mode 100644
index 0000000..5f9296c
--- /dev/null
+++ b/utils/grpcreadclient/grpcreadclient.go
@@ -0,0 +1,599 @@
+// This is a client which is used by .bazelci/tls-tests.sh and
+// ~/.bazelci/basic-auth-tests.sh to verify read/write access
+// with and without authentication.
+
+package main
+
+import (
+	"context"
+	"crypto/sha256"
+	"crypto/tls"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/hex"
+	"flag"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"os"
+	"time"
+
+	pb "github.com/buchgr/bazel-remote/genproto/build/bazel/remote/execution/v2"
+	"github.com/google/uuid"
+
+	"google.golang.org/genproto/googleapis/bytestream"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/credentials"
+	"google.golang.org/grpc/status"
+)
+
+var emptyDigest = pb.Digest{
+	Hash:      "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+	SizeBytes: 0,
+}
+
+func main() {
+	// Always required:
+	serverAddr := flag.String("server-addr", "",
+		"gRPC server address in the form hostname:port")
+
+	readsShouldWork := flag.Bool("reads-should-work", false,
+		"whether the server should allow read access in this test")
+
+	// Required for mTLS:
+	clientCertFile := flag.String("client-cert-file", "",
+		"TLS client certificate file (for mTLS)")
+	clientKeyFile := flag.String("client-key-file", "",
+		"TLS client key file (for mTLS)")
+
+	// Required when the server uses TLS:
+	caCertFile := flag.String("ca-cert-file", "",
+		"TLS CA certificate file which signed the server's cert")
+
+	basicAuthUser := flag.String("basic-auth-user", "",
+		"Username for basic authentication")
+
+	basicAuthPass := flag.String("basic-auth-pass", "",
+		"Password for basic authentication")
+
+	showHelp := flag.Bool("help", false, "Show help")
+
+	flag.Parse()
+
+	if *showHelp {
+		flag.PrintDefaults()
+		os.Exit(1)
+	}
+
+	if *serverAddr == "" {
+		fmt.Println("Error: -server-addr must be specified")
+		os.Exit(1)
+	}
+
+	writesShouldWork := false
+
+	basicAuthFlagCount := 0
+	if *basicAuthUser != "" {
+		basicAuthFlagCount++
+	}
+	if *basicAuthPass != "" {
+		basicAuthFlagCount++
+	}
+	if basicAuthFlagCount != 0 {
+		if basicAuthFlagCount != 2 {
+			fmt.Println("Error: if one of -basic-auth-user or -basic-auth-path are specified, then both must be")
+			os.Exit(1)
+		}
+
+		*readsShouldWork = true
+		writesShouldWork = true
+	}
+
+	if *clientCertFile != "" {
+		*readsShouldWork = true
+		writesShouldWork = true
+	}
+
+	if *clientCertFile != "" && basicAuthFlagCount != 0 {
+		fmt.Println("Error: only one of basic authentication and mTLS can be used")
+		os.Exit(1)
+	}
+
+	err := run(*serverAddr, *readsShouldWork, writesShouldWork, *clientCertFile, *clientKeyFile, *caCertFile, *basicAuthUser, *basicAuthPass)
+	if err != nil {
+		fmt.Println(err)
+		os.Exit(1)
+	}
+}
+
+type basicAuthCreds struct {
+	user string
+	pass string
+}
+
+func (b *basicAuthCreds) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
+	return map[string]string{
+		"authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte(b.user+":"+b.pass)),
+	}, nil
+}
+
+func (b *basicAuthCreds) RequireTransportSecurity() bool {
+	return true
+}
+
+func dial(serverAddr string, caCertFile string, clientCertFile string, clientKeyFile string, basicAuthUser string, basicAuthPass string) (*grpc.ClientConn, error, context.Context, context.CancelFunc) {
+
+	dialOpts := []grpc.DialOption{grpc.WithBlock()}
+
+	if basicAuthUser != "" {
+		authority := fmt.Sprintf("%s:%s@%s", basicAuthUser, basicAuthPass, serverAddr)
+		dialOpts = append(dialOpts, grpc.WithAuthority(authority))
+	}
+
+	if caCertFile == "" {
+		dialOpts = append(dialOpts, grpc.WithInsecure())
+	} else {
+		fmt.Println("reading", caCertFile)
+
+		caCertData, err := ioutil.ReadFile(caCertFile)
+		if err != nil {
+			return nil, fmt.Errorf("Failed to read CA cert file %q: %w",
+				caCertFile, err), nil, nil
+		}
+
+		pool := x509.NewCertPool()
+		if !pool.AppendCertsFromPEM(caCertData) {
+			return nil, fmt.Errorf("Failed to create CA certificate pool"), nil, nil
+		}
+
+		tlsCfg := &tls.Config{RootCAs: pool}
+
+		if clientCertFile != "" {
+			clientCert, err := tls.LoadX509KeyPair(clientCertFile, clientKeyFile)
+			if err != nil {
+				return nil, fmt.Errorf("Failed to read client cert file %q (key file %q): %w",
+					clientCertFile, clientKeyFile, err), nil, nil
+			}
+
+			tlsCfg.Certificates = []tls.Certificate{clientCert}
+		}
+
+		creds := credentials.NewTLS(tlsCfg)
+
+		dialOpts = append(dialOpts, grpc.WithTransportCredentials(creds))
+	}
+
+	fmt.Println("Dialing...", serverAddr)
+
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
+	conn, err := grpc.DialContext(ctx, serverAddr, dialOpts...)
+	return conn, err, ctx, cancel
+}
+
+func run(serverAddr string, readsShouldWork bool, writesShouldWork bool, clientCertFile string, clientKeyFile string, caCertFile string, basicAuthUser string, basicAuthPass string) error {
+
+	conn, err, ctx, cancel := dial(serverAddr, caCertFile, clientCertFile, clientKeyFile, basicAuthUser, basicAuthPass)
+	if conn != nil {
+		defer conn.Close()
+	}
+	defer cancel()
+
+	select {
+	case <-ctx.Done():
+		if !readsShouldWork && !writesShouldWork {
+			fmt.Println("Gave up dialing, as expected:", ctx.Err())
+			return nil
+		}
+
+		return fmt.Errorf("Failed to connect to %q: %w", serverAddr, ctx.Err())
+	default:
+	}
+
+	if err != nil || conn == nil {
+		return fmt.Errorf("Failed to connect %q: %w", serverAddr, err)
+	}
+
+	fmt.Println("Connected.")
+
+	err = checkGetCapabilities(conn, readsShouldWork)
+	if err != nil {
+		return err
+	}
+
+	err = checkCacheReadOps(conn, readsShouldWork)
+	if err != nil {
+		return err
+	}
+
+	err = checkCacheWriteOps(conn, writesShouldWork)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func checkGetCapabilities(conn *grpc.ClientConn, shouldWork bool) error {
+	capClient := pb.NewCapabilitiesClient(conn)
+
+	_, err := capClient.GetCapabilities(context.Background(),
+		&pb.GetCapabilitiesRequest{})
+
+	if !shouldWork {
+		if err != nil {
+			fmt.Println("GetCapabilities failed, as expected")
+			return nil
+		}
+		return fmt.Errorf("Got capabilities when we expected it to fail")
+	}
+
+	if err != nil {
+		return fmt.Errorf("Failed to get capabilities: %w", err)
+	}
+
+	fmt.Println("GetCapabilities succeeded, as expected")
+	return nil
+}
+
+func checkCacheReadOps(conn *grpc.ClientConn, shouldWork bool) error {
+
+	casClient := pb.NewContentAddressableStorageClient(conn)
+
+	var err error
+
+	err = checkBatchReadBlobs(casClient, shouldWork)
+	if err != nil {
+		return err
+	}
+
+	err = checkFindMissingBlobs(casClient, shouldWork)
+	if err != nil {
+		return err
+	}
+
+	acClient := pb.NewActionCacheClient(conn)
+
+	err = checkGetActionResult(acClient, shouldWork)
+	if err != nil {
+		return err
+	}
+
+	bsClient := bytestream.NewByteStreamClient(conn)
+
+	err = checkBytestreamRead(bsClient, shouldWork)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func checkBatchReadBlobs(casClient pb.ContentAddressableStorageClient, shouldWork bool) error {
+	bg := context.Background()
+	brResp, err := casClient.BatchReadBlobs(bg, &pb.BatchReadBlobsRequest{
+		Digests: []*pb.Digest{&emptyDigest},
+	})
+
+	if !shouldWork {
+		if err != nil {
+			fmt.Println("BatchReadBlobs failed, as expected")
+			return nil
+		}
+		return fmt.Errorf("BatchReadBlobs succeeded but was expected to fail")
+	}
+
+	if err != nil {
+		return fmt.Errorf("BatchReadBlobs failed: %w", err)
+	}
+
+	if len(brResp.Responses) != 1 {
+		return fmt.Errorf("Error: expected 1 digest in the response, found: %d",
+			len(brResp.Responses))
+	}
+
+	if brResp.Responses[0] == nil {
+		return fmt.Errorf("Error: found nil reponse")
+	}
+
+	if brResp.Responses[0].Status.Code != int32(codes.OK) {
+		return fmt.Errorf("Error: unexpected reponse: %s",
+			brResp.Responses[0].Status.GetMessage())
+	}
+
+	if brResp.Responses[0].Digest == nil {
+		return fmt.Errorf("Error: found nil digest")
+	}
+
+	if brResp.Responses[0].Digest.Hash != emptyDigest.Hash || brResp.Responses[0].Digest.SizeBytes != 0 {
+		return fmt.Errorf("Error: found different digest")
+	}
+
+	if len(brResp.Responses[0].Data) != 0 {
+		return fmt.Errorf("Error: found non-empty Data")
+	}
+
+	fmt.Println("BatchReadBlobs succeeded, as expected")
+	return nil
+}
+
+func checkFindMissingBlobs(casClient pb.ContentAddressableStorageClient, shouldWork bool) error {
+	req := pb.FindMissingBlobsRequest{
+		BlobDigests: []*pb.Digest{&emptyDigest},
+	}
+
+	resp, err := casClient.FindMissingBlobs(context.Background(), &req)
+	if !shouldWork {
+		if err != nil {
+			fmt.Println("FindMissingBlobsRequest failed, as expected")
+			return nil
+		}
+
+		return fmt.Errorf("Expected FindMissingBlobs to fail, but it succeeded")
+	}
+
+	if err != nil {
+		return fmt.Errorf("FindMissingBlobs failed: %w", err)
+	}
+
+	if len(resp.MissingBlobDigests) != 0 {
+		// The empty blob should always be available.
+		return fmt.Errorf("Expected no missing blobs from FindMissingBlobs call")
+	}
+
+	fmt.Println("FindMissingBlobsRequest succeeded, as expected")
+	return nil
+}
+
+func checkGetActionResult(acClient pb.ActionCacheClient, shouldWork bool) error {
+
+	// We don't expect the cache to contain this entry.
+	req := pb.GetActionResultRequest{
+		ActionDigest: &emptyDigest,
+	}
+
+	ar, err := acClient.GetActionResult(context.Background(), &req)
+
+	if !shouldWork {
+		if ar != nil {
+			return fmt.Errorf("Expected GetActionResult to fail, but it returned a non-nil ActionResult")
+		}
+
+		if err == nil {
+			return fmt.Errorf("Expected GetActionResult to fail and return a non-nil error, but the error was nil")
+		}
+
+		if status.Code(err) != codes.Unauthenticated {
+			return fmt.Errorf("Expected GetActionResult to fail and return Unauthenticated, but got: %s", err.Error())
+		}
+
+		fmt.Println("GetActionResult failed, as expected")
+		return nil
+	}
+
+	if err == nil {
+		return fmt.Errorf("Expected a GetActionResult to return NotFound, but it succeeded somehow")
+	}
+
+	if status.Code(err) != codes.NotFound {
+		return fmt.Errorf("Expected a GetActionResult to return NotFound, but got: %s", err.Error())
+	}
+
+	fmt.Println("GetActionResult succeeded, as expected")
+	return nil
+}
+
+func checkBytestreamRead(bsClient bytestream.ByteStreamClient, shouldWork bool) error {
+
+	resource := fmt.Sprintf("emptyRead/blobs/%s/0", emptyDigest.Hash)
+	req := bytestream.ReadRequest{ResourceName: resource}
+
+	bsrc, err := bsClient.Read(context.Background(), &req)
+
+	if !shouldWork {
+		if err != nil {
+			fmt.Println("BytestreamRead failed, as expected")
+			return nil
+		}
+
+		_, err := bsrc.Recv() // We seem to fail here. Not sure why not above.
+		if err != nil {
+			fmt.Println("BytestreamRead failed, as expected")
+			return nil
+		}
+
+		return fmt.Errorf("Expected bytestream Read to fail, but it succeeded")
+	}
+
+	if err != nil {
+		return fmt.Errorf("Expected bytestream Read to succeed, got %w", err)
+	}
+
+	var downloadedBlob []byte
+	for {
+		bsrResp, err := bsrc.Recv()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return fmt.Errorf("Expected success, got %w", err)
+		}
+		if bsrResp == nil {
+			return fmt.Errorf("Expected non-nil response")
+		}
+
+		downloadedBlob = append(downloadedBlob, bsrResp.Data...)
+
+		if len(downloadedBlob) > 0 {
+			return fmt.Errorf("Downloaded too much data, expected an empty blob")
+		}
+	}
+
+	fmt.Println("BytestreamRead succeeded, as expected")
+	return nil
+}
+
+func checkCacheWriteOps(conn *grpc.ClientConn, shouldWork bool) error {
+	var err error
+
+	casClient := pb.NewContentAddressableStorageClient(conn)
+
+	err = checkBatchUpdateBlobs(casClient, shouldWork)
+	if err != nil {
+		return err
+	}
+
+	acClient := pb.NewActionCacheClient(conn)
+
+	err = checkUpdateActionResult(acClient, shouldWork)
+	if err != nil {
+		return err
+	}
+
+	bsClient := bytestream.NewByteStreamClient(conn)
+
+	err = checkBytestreamWrite(bsClient, shouldWork)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func checkUpdateActionResult(acClient pb.ActionCacheClient, shouldWork bool) error {
+	// This is the most important request to get right.
+
+	req := pb.UpdateActionResultRequest{
+		ActionDigest: &pb.Digest{
+			Hash:      "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
+			SizeBytes: 5,
+		},
+		ActionResult: &pb.ActionResult{
+			ExitCode: int32(42),
+		},
+	}
+
+	_, err := acClient.UpdateActionResult(context.Background(), &req)
+
+	if !shouldWork {
+		if err != nil {
+			fmt.Println("UpdateActionResult failed, as expected")
+			return nil
+		}
+
+		return fmt.Errorf("Expected UpdateActionResult to fail, but it succeeded")
+	}
+
+	if err != nil {
+		return fmt.Errorf("Expected UpdateActionResult to succeed, got: %w", err)
+	}
+
+	fmt.Println("UpdateActionResult succeeded, as expected")
+	return nil
+}
+
+func checkBatchUpdateBlobs(casClient pb.ContentAddressableStorageClient, shouldWork bool) error {
+
+	ur := pb.BatchUpdateBlobsRequest_Request{
+		Digest: &pb.Digest{
+			Hash:      "1d996e033d612d9af2b44b70061ee0e868bfd14c2dd90b129e1edeb7953e7985",
+			SizeBytes: 10,
+		},
+		Data: []byte("hellothere"),
+	}
+	req := pb.BatchUpdateBlobsRequest{Requests: []*pb.BatchUpdateBlobsRequest_Request{&ur}}
+
+	resp, err := casClient.BatchUpdateBlobs(context.Background(), &req)
+
+	if !shouldWork {
+		if err != nil {
+			fmt.Println("BatchUpdateBlobs failed, as expected")
+			return nil
+		}
+
+		return fmt.Errorf("Expected BatchUpdateBlobs to fail, but it succeeded")
+	}
+
+	if err != nil {
+		return fmt.Errorf("Expected BatchUpdateBlobs to succeed, got %w", err)
+	}
+
+	rs := resp.GetResponses()
+	if len(rs) != 1 {
+		return fmt.Errorf("Expected BatchUpdateBlobs to have 1 reponse, found %d", len(rs))
+	}
+
+	if rs[0].Digest.Hash != ur.Digest.Hash {
+		return fmt.Errorf("Unexpected digest in reponse")
+	}
+
+	if rs[0].Digest.SizeBytes != ur.Digest.SizeBytes {
+		return fmt.Errorf("Unexpected digest in reponse")
+	}
+
+	if rs[0].Status != nil && rs[0].Status.Code != int32(codes.OK) {
+		return fmt.Errorf("Unexpected unsuccessful response")
+	}
+
+	fmt.Println("BatchUpdateBlobs succeeded, as expected")
+	return nil
+}
+
+func checkBytestreamWrite(bsClient bytestream.ByteStreamClient, shouldWork bool) error {
+	bswc, err := bsClient.Write(context.Background())
+	if err != nil {
+		return fmt.Errorf("BytestreamWrite failed to create ByteStreamClient, got: %w", err)
+	}
+
+	blob := []byte("checkBytestreamWrite.testData")
+	hash := sha256.Sum256(blob)
+	blobDigest := pb.Digest{
+		Hash:      hex.EncodeToString(hash[:]),
+		SizeBytes: int64(len(blob)),
+	}
+
+	resource := fmt.Sprintf("uploads/%s/blobs/%s/%d",
+		uuid.New().String(), blobDigest.Hash, blobDigest.SizeBytes)
+
+	req := bytestream.WriteRequest{
+		ResourceName: resource,
+		FinishWrite:  true,
+		Data:         blob,
+	}
+
+	err = bswc.Send(&req)
+	if err != nil {
+		return fmt.Errorf("BytestreamWrite failed to send data: %w", err)
+	}
+
+	resp, err := bswc.CloseAndRecv()
+	if !shouldWork {
+		if err != nil {
+			statusErr, ok := status.FromError(err)
+			if ok {
+				if statusErr.Code() == codes.Unauthenticated {
+					fmt.Println("BytestreamWrite failed, as expected")
+					return nil
+				}
+
+				return fmt.Errorf("BytestreamWrite got unexpected unauthenticated error: %w", err)
+			}
+
+			return fmt.Errorf("BytestreamWrite got unexpected unauthenticated error: %w", err)
+		}
+
+		return fmt.Errorf("BytestreamWrite CloseAndRecv succeeded, but expected it to fail")
+	}
+
+	if err != nil {
+		return fmt.Errorf("BytestreamWrite failed to CloseAndRecv: %w", err)
+	}
+
+	if resp.CommittedSize != int64(len(blob)) {
+		return fmt.Errorf("BytestreamWrite expected to write %d bytes, but committed %d",
+			len(blob), resp.CommittedSize)
+	}
+
+	fmt.Println("BytestreamWrite succeeded, as expected")
+	return nil
+}


Test patch:
diff --git a/.bazelci/basic-auth-tests.sh b/.bazelci/basic-auth-tests.sh
new file mode 100755
index 0000000..8dc2dca
--- /dev/null
+++ b/.bazelci/basic-auth-tests.sh
@@ -0,0 +1,155 @@
+#!/usr/bin/env bash
+
+set -v
+set -e
+set -u
+set -o pipefail
+
+SRC_ROOT=$(dirname "$0")/..
+SRC_ROOT=$(realpath "$SRC_ROOT")
+cd "$SRC_ROOT"
+
+HTTP_PORT=8089
+USER=topsecretusername
+PASS=topsecretpassword
+
+#export GRPC_GO_LOG_VERBOSITY_LEVEL=99
+#export GRPC_GO_LOG_SEVERITY_LEVEL=info
+
+tmpdir=$(mktemp -d bazel-remote-basic-auth-tests.XXXXXXX --tmpdir=${TMPDIR:-/tmp})
+
+[ -e bazel-remote ] || ./linux-build.sh
+
+# Generated with "htpasswd -b -c htpasswd $USER $PASS"
+echo 'topsecretusername:$apr1$Ke2kcK4W$EyueqiHyoqhwXcpiEGNyJ1' \
+	> "$tmpdir/htpasswd"
+
+echo "Starting bazel-remote, allowing unauthenticated reads..."
+./bazel-remote --dir "$tmpdir/cache" --max_size 1 --port "$HTTP_PORT" \
+	--htpasswd_file "$tmpdir/htpasswd" \
+	--allow_unauthenticated_reads > "$tmpdir/bazel-remote.log" 2>&1 &
+server_pid=$!
+
+# Wait a bit for the server start up...
+
+running=false
+for i in $(seq 1 20)
+do
+	sleep 1
+
+	if wget --inet4-only -d -O - --timeout=2 \
+		--http-user "$USER" --http-password "$PASS" \
+		"http://localhost:$HTTP_PORT/status"
+	then
+		running=true
+		break
+	fi
+done
+
+if [ "$running" != true ]
+then
+	echo "Error: bazel-remote took too long to start"
+	kill -9 $server_pid
+	exit 1
+fi
+
+# Authenticated read.
+wget --inet4-only -d -O - \
+	--http-user "$USER" --http-password "$PASS" \
+	http://localhost:$HTTP_PORT/status
+
+# Unauthenticated read.
+wget --inet4-only -d -O - http://localhost:$HTTP_PORT/status
+
+# Run without auth, expect readonly access.
+bazel run //utils/grpcreadclient -- -server-addr localhost:9092 \
+	-reads-should-work
+
+# Run with auth, expect read-write access.
+bazel run //utils/grpcreadclient -- -server-addr localhost:9092 \
+	-basic-auth-user "$USER" -basic-auth-pass "$PASS"
+
+# Authenticated build, populate the cache.
+bazel clean
+bazel build //:bazel-remote --remote_cache=grpc://$USER:$PASS@localhost:9092
+
+# Unauthenticated build, don't attempt to upload (gRPC).
+bazel clean
+bazel build //:bazel-remote --remote_cache=grpc://localhost:9092 \
+	--noremote_upload_local_results
+
+# Unauthenticated build, don't attempt to upload (HTTP).
+bazel clean
+bazel build //:bazel-remote --remote_cache=http://localhost:$HTTP_PORT \
+	--noremote_upload_local_results
+
+# Unauthenticated gRPC client, should fail to write, but the build
+# should succeed.
+bazel clean
+bazel build //:bazel-remote --remote_cache=grpc://localhost:9092 \
+	2>&1 | tee "$tmpdir/unauthenticated_write.log"
+
+grep -A 1 "WARNING: Writing to Remote Cache:" "$tmpdir/unauthenticated_write.log" | \
+	tr '\n' '|' > "$tmpdir/unauthenticated_write.log.singleline"
+if ! grep --silent "WARNING: Writing to Remote Cache:|BulkTransferException|" "$tmpdir/unauthenticated_write.log.singleline"
+then
+	# We seem to always have one cache miss with a rebuild.
+	# So we expect a single cache write attempt, and it should fail.
+	echo "Error: expected a warning when writing to the remote cache fails"
+	exit 1
+fi
+
+# Restart the server with authentication enabled but unauthenticated reads disabled.
+kill -9 $server_pid
+./bazel-remote --dir "$tmpdir/cache" --max_size 1 --port "$HTTP_PORT" \
+	--htpasswd_file "$tmpdir/htpasswd" > "$tmpdir/bazel-remote-authenticated.log" 2>&1 &
+server_pid=$!
+
+# Wait a bit for the server start up...
+
+running=false
+for i in $(seq 1 20)
+do
+	sleep 1
+
+	if wget --inet4-only -d -O - --timeout=2 \
+		--http-user "$USER" --http-password "$PASS" \
+		"http://localhost:$HTTP_PORT/status"
+	then
+		running=true
+		break
+	fi
+done
+
+if [ "$running" != true ]
+then
+	echo "Error: bazel-remote took too long to start"
+	kill -9 $server_pid
+	exit 1
+fi
+
+# Authenticated read should succeed.
+wget --inet4-only -d -O - --timeout=2 \
+	--http-user "$USER" --http-password "$PASS" \
+	"http://localhost:$HTTP_PORT/cas/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+
+# Unauthenticated read should fail.
+if wget --inet4-only -d -O - --timeout=2 \
+	"http://localhost:$HTTP_PORT/cas/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+then
+	echo "Error: expected unauthenticated read to fail"
+	kill -9 $server_pid
+	exit 1
+fi
+
+# Run without auth, expect no access.
+bazel run //utils/grpcreadclient -- -server-addr localhost:9092
+
+# Run with auth, expect full access.
+bazel run //utils/grpcreadclient -- -server-addr localhost:9092 \
+	-basic-auth-user "$USER" -basic-auth-pass "$PASS"
+
+# Clean up...
+
+kill -9 $server_pid
+rm -rf "$tmpdir"
diff --git a/.bazelci/tls-tests.sh b/.bazelci/tls-tests.sh
index 6ca22d9..e3cbcd7 100755
--- a/.bazelci/tls-tests.sh
+++ b/.bazelci/tls-tests.sh
@@ -1,6 +1,6 @@
 #!/usr/bin/env bash
 
-#set -x
+set -v
 set -e
 set -u
 set -o pipefail
@@ -11,7 +11,10 @@ cd "$SRC_ROOT"
 
 HTTP_PORT=8089
 
-tmpdir=$(mktemp -d bazel-remote-mtls-tests.XXXXXXX --tmpdir=${TMPDIR:-/tmp})
+#export GRPC_GO_LOG_VERBOSITY_LEVEL=99
+#export GRPC_GO_LOG_SEVERITY_LEVEL=info
+
+tmpdir=$(mktemp -d bazel-remote-tls-tests.XXXXXXX --tmpdir=${TMPDIR:-/tmp})
 
 generate_keys() {
 	# Copied from https://github.com/bazelbuild/bazel/blob/master/src/test/testdata/test_tls_certificate/README.md
@@ -32,8 +35,20 @@ generate_keys() {
 	openssl req -passin pass:1111 -new -key "$tmpdir/server.key" \
 		-out "$tmpdir/server.csr" -subj "/CN=${SERVER_CN}"
 
+	# Add subjectAltName aka "SAN", which replaces CN.
+	# Required for Go >= 1.15.
+	cat << EOF > domain.ext
+authorityKeyIdentifier=keyid,issuer
+basicConstraints=CA:FALSE
+keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
+subjectAltName = @alt_names
+[alt_names]
+DNS.1 = localhost
+EOF
+
 	# Self-signed server certificate:
 	openssl x509 -req -passin pass:1111 -days 358000 -in "$tmpdir/server.csr" \
+		-extfile domain.ext \
 		-CA "$tmpdir/ca.crt" -CAkey "$tmpdir/ca.key" -set_serial 01 -out "$tmpdir/server.crt"
 
 	# Remove passphrase from server key:
@@ -64,17 +79,18 @@ generate_keys
 
 [ -e bazel-remote ] || ./linux-build.sh
 
-echo "Starting bazel-remote..."
+echo "Starting bazel-remote, allowing unauthenticated reads..."
 ./bazel-remote --dir "$tmpdir/cache" --max_size 1 --port "$HTTP_PORT" \
 	--tls_cert_file "$tmpdir/server.crt" \
 	--tls_key_file "$tmpdir/server.key" \
-	--tls_ca_file "$tmpdir/ca.crt" > "$tmpdir/bazel-remote.log" 2>&1 &
+	--tls_ca_file "$tmpdir/ca.crt" \
+	--allow_unauthenticated_reads > "$tmpdir/bazel-remote.log" 2>&1 &
 server_pid=$!
 
 # Wait a bit for the server start up...
 
 running=false
-for i in $(seq 1 10)
+for i in $(seq 1 20)
 do
 	sleep 1
 
@@ -96,12 +112,123 @@ then
 	exit 1
 fi
 
+# Authenticated read.
+wget --inet4-only -d -O - --ca-certificate=$tmpdir/server.crt \
+	--certificate=$tmpdir/client.crt \
+	--private-key=$tmpdir/client.pem \
+	https://localhost:$HTTP_PORT/status
+
+# Unauthenticated read.
+wget --inet4-only -d -O - --ca-certificate=$tmpdir/server.crt \
+	https://localhost:$HTTP_PORT/status
+
+# Run without auth, expect readonly access.
+bazel run //utils/grpcreadclient -- -server-addr localhost:9092 \
+	-ca-cert-file "$tmpdir/ca.crt" \
+	-reads-should-work
+
+# Run with auth, expect read-write access.
+bazel run //utils/grpcreadclient -- -server-addr localhost:9092 \
+	-ca-cert-file "$tmpdir/ca.crt" \
+	-client-cert-file "$tmpdir/client.crt" \
+	-client-key-file "$tmpdir/client.key"
+
+# Authenticated build, populate the cache.
 bazel clean
 bazel build //:bazel-remote --remote_cache=grpcs://localhost:9092 \
 	--tls_certificate "$tmpdir/ca.crt" \
 	--tls_client_certificate "$tmpdir/client.crt" \
 	--tls_client_key "$tmpdir/client.pem"
 
+# Unauthenticated build, don't attempt to upload (gRPC).
+bazel clean
+bazel build //:bazel-remote --remote_cache=grpcs://localhost:9092 \
+	--tls_certificate "$tmpdir/ca.crt" \
+	--noremote_upload_local_results
+
+# Unauthenticated build, don't attempt to upload (HTTP).
+bazel clean
+bazel build //:bazel-remote --remote_cache=https://localhost:$HTTP_PORT \
+	--tls_certificate "$tmpdir/ca.crt" \
+	--noremote_upload_local_results
+
+# Unauthenticated gRPC client, should fail to write, but the build
+# should succeed.
+bazel clean
+bazel build //:bazel-remote --remote_cache=grpcs://localhost:9092 \
+	--tls_certificate "$tmpdir/ca.crt" \
+	2>&1 | tee "$tmpdir/unauthenticated_write.log"
+
+grep -A 1 "WARNING: Writing to Remote Cache:" "$tmpdir/unauthenticated_write.log" | \
+	tr '\n' '|' > "$tmpdir/unauthenticated_write.log.singleline"
+if ! grep --silent "WARNING: Writing to Remote Cache:|BulkTransferException|" "$tmpdir/unauthenticated_write.log.singleline"
+then
+	# We seem to always have one cache miss with a rebuild.
+	# So we expect a single cache write attempt, and it should fail.
+	echo "Error: expected a warning when writing to the remote cache fails"
+	exit 1
+fi
+
+# Restart the server with authentication enabled but unauthenticated reads disabled.
+kill -9 $server_pid
+./bazel-remote --dir "$tmpdir/cache" --max_size 1 --port "$HTTP_PORT" \
+	--tls_cert_file "$tmpdir/server.crt" \
+	--tls_key_file "$tmpdir/server.key" \
+	--tls_ca_file "$tmpdir/ca.crt" > "$tmpdir/bazel-remote-authenticated.log" 2>&1 &
+server_pid=$!
+
+# Wait a bit for the server start up...
+
+running=false
+for i in $(seq 1 20)
+do
+	sleep 1
+
+	if wget --inet4-only -d -O - --ca-certificate=$tmpdir/server.crt \
+		--certificate=$tmpdir/client.crt \
+		--private-key=$tmpdir/client.pem \
+		--timeout=2 \
+		"https://localhost:$HTTP_PORT/status"
+	then
+		running=true
+		break
+	fi
+done
+
+if [ "$running" != true ]
+then
+	echo "Error: bazel-remote took too long to start"
+	kill -9 $server_pid
+	exit 1
+fi
+
+# Authenticated read should succeed.
+wget --inet4-only -d -O - --ca-certificate=$tmpdir/server.crt \
+	--certificate=$tmpdir/client.crt \
+	--private-key=$tmpdir/client.pem \
+	--timeout=2 \
+	"https://localhost:$HTTP_PORT/cas/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+
+# Unauthenticated read should fail.
+if wget --inet4-only -d -O - --ca-certificate=$tmpdir/server.crt \
+	--timeout=2 \
+	"https://localhost:$HTTP_PORT/cas/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+then
+	echo "Error: expected unauthenticated read to fail"
+	kill -9 $server_pid
+	exit 1
+fi
+
+# Run without auth, expect no access.
+bazel run //utils/grpcreadclient -- -server-addr localhost:9092
+
+# Run with auth, expect full access.
+bazel run //utils/grpcreadclient -- -server-addr localhost:9092 \
+	-ca-cert-file "$tmpdir/ca.crt" \
+	-client-cert-file "$tmpdir/client.crt" \
+	-client-key-file "$tmpdir/client.key"
+
+# Clean up...
 
 kill -9 $server_pid
 rm -rf "$tmpdir"
diff --git a/server/grpc_bytestream.go b/server/grpc_bytestream.go
index db25e49..a2e4826 100644
--- a/server/grpc_bytestream.go
+++ b/server/grpc_bytestream.go
@@ -339,6 +339,13 @@ var errDecoderPoolFail error = errors.New("failed to get DecoderWrapper from poo
 
 func (s *grpcServer) Write(srv bytestream.ByteStream_WriteServer) error {
 
+	if s.checkClientCertForWrites {
+		err := checkGRPCClientCert(srv.Context())
+		if err != nil {
+			return err
+		}
+	}
+
 	var resp bytestream.WriteResponse
 	pr, pw := io.Pipe()
 
diff --git a/server/grpc_test.go b/server/grpc_test.go
index 283ca69..364e5ea 100644
--- a/server/grpc_test.go
+++ b/server/grpc_test.go
@@ -89,6 +89,7 @@ func TestMain(m *testing.M) {
 	validateAC := true
 	mangleACKeys := false
 	enableRemoteAssetAPI := true
+	allowUnauthenticatedReads := false
 
 	go func() {
 		err2 := serveGRPC(
@@ -97,6 +98,7 @@ func TestMain(m *testing.M) {
 			validateAC,
 			mangleACKeys,
 			enableRemoteAssetAPI,
+			allowUnauthenticatedReads,
 			diskCache, accessLogger, errorLogger)
 		if err2 != nil {
 			fmt.Println(err2)
diff --git a/server/http_test.go b/server/http_test.go
index 23df1b2..c9b37df 100644
--- a/server/http_test.go
+++ b/server/http_test.go
@@ -38,7 +38,7 @@ func TestDownloadFile(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	h := NewHTTPCache(c, testutils.NewSilentLogger(), testutils.NewSilentLogger(), true, false, "")
+	h := NewHTTPCache(c, testutils.NewSilentLogger(), testutils.NewSilentLogger(), true, false, false, "")
 
 	rr := httptest.NewRecorder()
 	handler := http.HandlerFunc(h.CacheHandler)
@@ -103,7 +103,7 @@ func TestUploadFilesConcurrently(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	h := NewHTTPCache(c, testutils.NewSilentLogger(), testutils.NewSilentLogger(), true, false, "")
+	h := NewHTTPCache(c, testutils.NewSilentLogger(), testutils.NewSilentLogger(), true, false, false, "")
 	handler := http.HandlerFunc(h.CacheHandler)
 
 	var wg sync.WaitGroup
@@ -167,7 +167,7 @@ func TestUploadSameFileConcurrently(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	h := NewHTTPCache(c, testutils.NewSilentLogger(), testutils.NewSilentLogger(), true, false, "")
+	h := NewHTTPCache(c, testutils.NewSilentLogger(), testutils.NewSilentLogger(), true, false, false, "")
 	handler := http.HandlerFunc(h.CacheHandler)
 
 	var wg sync.WaitGroup
@@ -208,7 +208,7 @@ func TestUploadCorruptedFile(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	h := NewHTTPCache(c, testutils.NewSilentLogger(), testutils.NewSilentLogger(), true, false, "")
+	h := NewHTTPCache(c, testutils.NewSilentLogger(), testutils.NewSilentLogger(), true, false, false, "")
 	rr := httptest.NewRecorder()
 	handler := http.HandlerFunc(h.CacheHandler)
 	handler.ServeHTTP(rr, r)
@@ -250,7 +250,8 @@ func TestUploadEmptyActionResult(t *testing.T) {
 	}
 	validate := true
 	mangle := false
-	h := NewHTTPCache(c, testutils.NewSilentLogger(), testutils.NewSilentLogger(), validate, mangle, "")
+	unauthenticatedReads := false
+	h := NewHTTPCache(c, testutils.NewSilentLogger(), testutils.NewSilentLogger(), validate, mangle, unauthenticatedReads, "")
 	rr := httptest.NewRecorder()
 	handler := http.HandlerFunc(h.CacheHandler)
 	handler.ServeHTTP(rr, r)
@@ -310,7 +311,8 @@ func testEmptyBlobAvailable(t *testing.T, method string) {
 	}
 	validate := true
 	mangle := false
-	h := NewHTTPCache(c, testutils.NewSilentLogger(), testutils.NewSilentLogger(), validate, mangle, "")
+	unauthenticatedReads := false
+	h := NewHTTPCache(c, testutils.NewSilentLogger(), testutils.NewSilentLogger(), validate, mangle, unauthenticatedReads, "")
 	rr := httptest.NewRecorder()
 	handler := http.HandlerFunc(h.CacheHandler)
 	handler.ServeHTTP(rr, r)
@@ -333,7 +335,7 @@ func TestStatusPage(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	h := NewHTTPCache(c, testutils.NewSilentLogger(), testutils.NewSilentLogger(), true, false, "")
+	h := NewHTTPCache(c, testutils.NewSilentLogger(), testutils.NewSilentLogger(), true, false, false, "")
 	rr := httptest.NewRecorder()
 	handler := http.HandlerFunc(h.StatusPageHandler)
 	handler.ServeHTTP(rr, r)
@@ -477,7 +479,7 @@ func TestRemoteReturnsNotFound(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	h := NewHTTPCache(emptyCache, testutils.NewSilentLogger(), testutils.NewSilentLogger(), true, false, "")
+	h := NewHTTPCache(emptyCache, testutils.NewSilentLogger(), testutils.NewSilentLogger(), true, false, false, "")
 	// create a fake http.Request
 	_, hash := testutils.RandomDataAndHash(1024)
 	url, _ := url.Parse(fmt.Sprintf("http://localhost:8080/ac/%s", hash))