go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/auth_service/internal/gs/client.go (about) 1 // Copyright 2024 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package gs 16 17 import ( 18 "context" 19 "fmt" 20 "net/http" 21 "strings" 22 "time" 23 24 "cloud.google.com/go/storage" 25 "google.golang.org/api/option" 26 27 "go.chromium.org/luci/common/data/stringset" 28 "go.chromium.org/luci/common/errors" 29 "go.chromium.org/luci/common/logging" 30 "go.chromium.org/luci/server/auth" 31 ) 32 33 const ( 34 // The chunk size to use when uploading to GS. 35 // 36 // Must be under 10 MB to avoid hitting GAE URL Fetch request size 37 // limits and must be larger than 262144 bytes to satisfy GCS 38 // requirements. Recommended by GCS to be a multiple of 262144. 39 maxChunkSize = 262144 * 34 // ~= 9 MB 40 ) 41 42 // Client abstracts functionality to connect with and use Google 43 // Storage. 44 // 45 // Non-production implementations are used primarily for testing. 46 type Client interface { 47 // Close closes the connection to Google Storage. 48 Close() error 49 50 // UpdateReadACL updates the object ACLs to grant read access to the 51 // given readers; readers must be user emails. 52 UpdateReadACL(ctx context.Context, objectPath string, readers stringset.Set) error 53 54 // WriteFile writes the given data to the GS path with the object ACLs 55 // provided. 56 WriteFile(ctx context.Context, objectPath, contentType string, data []byte, acls []storage.ACLRule) error 57 } 58 59 type gsClient struct { 60 baseClient *storage.Client 61 } 62 63 // NewGSClient creates a new production Google Storage client; i.e. this 64 // client is actually Google Storage, not a mock. 65 func NewGSClient(ctx context.Context) (*gsClient, error) { 66 logging.Debugf(ctx, "Creating new Google Storage client") 67 tr, err := auth.GetRPCTransport(ctx, auth.AsSelf, auth.WithScopes(auth.CloudOAuthScopes...)) 68 if err != nil { 69 return nil, errors.Annotate(err, "aborting - failed setting up authenticated requests to Google Storage").Err() 70 } 71 72 var opts []option.ClientOption 73 if tr != nil { 74 opts = []option.ClientOption{ 75 option.WithHTTPClient(&http.Client{Transport: tr}), 76 } 77 } 78 79 client, err := storage.NewClient(ctx, opts...) 80 if err != nil { 81 return nil, errors.Annotate(err, "failed to create Google Storage client").Err() 82 } 83 84 return &gsClient{ 85 baseClient: client, 86 }, nil 87 } 88 89 func (c *gsClient) Close() error { 90 if c.baseClient != nil { 91 err := c.baseClient.Close() 92 if err != nil { 93 return err 94 } 95 c.baseClient = nil 96 } 97 return nil 98 } 99 100 func (c *gsClient) WriteFile(ctx context.Context, objectPath, contentType string, data []byte, acls []storage.ACLRule) (retErr error) { 101 if c.baseClient == nil { 102 return fmt.Errorf("aborting - no Google Storage client") 103 } 104 105 bucket, name, found := strings.Cut(objectPath, "/") 106 if !found { 107 return fmt.Errorf("aborting - invalid object path %s", objectPath) 108 } 109 110 writer := c.baseClient.Bucket(bucket).Object(name).NewWriter(ctx) 111 defer func() { 112 err := writer.Close() 113 if retErr == nil && err != nil { 114 retErr = errors.Annotate(err, "error uploading %s", objectPath).Err() 115 return 116 } 117 118 logging.Debugf(ctx, "GS write successful.\nAttributes for %s: %+v", 119 objectPath, writer.Attrs()) 120 }() 121 122 writer.ContentType = contentType 123 writer.ACL = acls 124 writer.ChunkSize = maxChunkSize 125 writer.ChunkRetryDeadline = 30 * time.Second 126 127 if _, err := writer.Write(data); err != nil { 128 return errors.Annotate(err, "error uploading %s", objectPath).Err() 129 } 130 131 return nil 132 } 133 134 func (c *gsClient) UpdateReadACL(ctx context.Context, objectPath string, readers stringset.Set) error { 135 if c.baseClient == nil { 136 return fmt.Errorf("aborting - no Google Storage client") 137 } 138 139 bucket, name, found := strings.Cut(objectPath, "/") 140 if !found { 141 return fmt.Errorf("aborting - invalid object path %s", objectPath) 142 } 143 144 acl := c.baseClient.Bucket(bucket).Object(name).ACL() 145 146 oldACL, err := acl.List(ctx) 147 if err != nil { 148 return errors.Annotate(err, "error listing ACLs").Err() 149 } 150 oldAccessors := stringset.New(len(oldACL)) 151 oldReaders := stringset.New(len(oldACL)) 152 for _, rule := range oldACL { 153 oldAccessors.Add(string(rule.Email)) 154 if rule.Role == storage.RoleReader { 155 oldReaders.Add(string(rule.Email)) 156 } 157 } 158 159 errs := errors.MultiError{} 160 // Only set read access for users that don't have any access to 161 // prevent overwriting existing roles to the reader role. 162 toAdd := readers.Difference(oldAccessors) 163 for reader := range toAdd { 164 user := storage.ACLEntity(fmt.Sprintf("user-%s", reader)) 165 if err := acl.Set(ctx, user, storage.RoleReader); err != nil { 166 logging.Errorf(ctx, "error granting read access to %s for user %s", objectPath, user) 167 errs = append(errs, err) 168 } 169 } 170 // Revoke read access for users that currently have read access but 171 // aren't in readers. 172 toDelete := oldReaders.Difference(readers) 173 for reader := range toDelete { 174 user := storage.ACLEntity(fmt.Sprintf("user-%s", reader)) 175 if err := acl.Delete(ctx, user); err != nil { 176 logging.Errorf(ctx, "error revoking read access to %s for user %s", objectPath, user) 177 errs = append(errs, err) 178 } 179 } 180 181 return errs.AsError() 182 }