go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/config_service/rpc/validate.go (about) 1 // Copyright 2023 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 rpc 16 17 import ( 18 "bytes" 19 "context" 20 "crypto/sha256" 21 "encoding/hex" 22 "errors" 23 "fmt" 24 "io" 25 "path" 26 "regexp" 27 "strings" 28 29 "github.com/klauspost/compress/gzip" 30 "google.golang.org/grpc/codes" 31 "google.golang.org/grpc/status" 32 "google.golang.org/protobuf/encoding/protojson" 33 34 "go.chromium.org/luci/auth/identity" 35 "go.chromium.org/luci/common/gcloud/gs" 36 "go.chromium.org/luci/common/logging" 37 cfgcommonpb "go.chromium.org/luci/common/proto/config" 38 "go.chromium.org/luci/config" 39 "go.chromium.org/luci/gae/service/datastore" 40 "go.chromium.org/luci/server/auth" 41 42 "go.chromium.org/luci/config_service/internal/acl" 43 "go.chromium.org/luci/config_service/internal/clients" 44 "go.chromium.org/luci/config_service/internal/common" 45 "go.chromium.org/luci/config_service/internal/model" 46 "go.chromium.org/luci/config_service/internal/validation" 47 configpb "go.chromium.org/luci/config_service/proto" 48 ) 49 50 // validator is implemented by `validation.Validator`. 51 type validator interface { 52 Examine(context.Context, config.Set, []validation.File) (*validation.ExamineResult, error) 53 Validate(context.Context, config.Set, []validation.File) (*cfgcommonpb.ValidationResult, error) 54 } 55 56 // ValidateConfigs validates configs. Implements configpb.ConfigsServer. 57 func (c Configs) ValidateConfigs(ctx context.Context, req *configpb.ValidateConfigsRequest) (*cfgcommonpb.ValidationResult, error) { 58 logValidateRequest(ctx, req) 59 if err := checkValidateRequest(req); err != nil { 60 return nil, status.Errorf(codes.InvalidArgument, "%s", err) 61 } 62 cs := config.Set(req.GetConfigSet()) 63 ctx = logging.SetField(ctx, "ConfigSet", req.GetConfigSet()) 64 65 // ACL check 66 if auth.CurrentIdentity(ctx).Kind() == identity.Anonymous { 67 return nil, status.Error(codes.PermissionDenied, "user must be authenticated to validate config") 68 } 69 switch allowed, err := acl.CanValidateConfigSet(ctx, cs); { 70 case err != nil: 71 logging.Errorf(ctx, "failed to check validate acls: %s", err) 72 return nil, status.Errorf(codes.Internal, "error while checking acls") 73 case !allowed: 74 return nil, status.Errorf(codes.PermissionDenied, "%q does not have permission to validate config set %q", auth.CurrentIdentity(ctx), cs) 75 } 76 77 // Skip validation if config set does not exist 78 switch result, err := datastore.Exists(ctx, &model.ConfigSet{ID: cs}); { 79 case err != nil: 80 logging.Errorf(ctx, "failed to check the existence of config set: %s", err) 81 return nil, status.Errorf(codes.Internal, "error while checking the existence of config set %q", cs) 82 case !result.All(): 83 return &cfgcommonpb.ValidationResult{ 84 Messages: []*cfgcommonpb.ValidationResult_Message{ 85 { 86 Path: ".", 87 Severity: cfgcommonpb.ValidationResult_WARNING, 88 Text: "The config set is not registered, skipping validation", 89 }, 90 }, 91 }, nil 92 } 93 94 // Validation starts 95 files := c.makeValidationFiles(auth.CurrentIdentity(ctx), req.GetFileHashes()) 96 switch examineResult, err := c.Validator.Examine(ctx, cs, files); { 97 case err != nil: 98 logging.Errorf(ctx, "failed to examine the config files for validation: %s", err) 99 return nil, status.Errorf(codes.Internal, "failed to examine the config files for validation") 100 case examineResult.Passed(): 101 res, err := c.Validator.Validate(ctx, cs, files) 102 if err != nil { 103 logging.Errorf(ctx, "failed to validate the configs: %s", err) 104 return nil, status.Error(codes.Internal, "failed to validate the configs") 105 } 106 return res, nil 107 default: 108 grpcStatus, err := status.New(codes.InvalidArgument, "invalid validate config request. See status detail for fix instruction.").WithDetails(convertToFixInfo(examineResult)) 109 if err != nil { 110 return nil, status.Errorf(codes.Internal, "failed to construct return status: %s", err) 111 } 112 return nil, grpcStatus.Err() 113 } 114 } 115 116 func logValidateRequest(ctx context.Context, req *configpb.ValidateConfigsRequest) { 117 reqJSON, err := protojson.Marshal(req) 118 if err != nil { 119 // unexpected but marshal error is fine here, just log the error. 120 logging.Errorf(ctx, "failed to marshal the request to JSON: %s", err) 121 return 122 } 123 logging.Debugf(ctx, "received validation request from %q. Request: %s", auth.CurrentIdentity(ctx), reqJSON) 124 } 125 126 // checkValidateRequest does a sanity check on `ValidateConfigsRequest`. 127 func checkValidateRequest(req *configpb.ValidateConfigsRequest) error { 128 if cs := req.GetConfigSet(); cs == "" { 129 return errors.New("config set is required") 130 } else if err := config.Set(cs).Validate(); err != nil { 131 return fmt.Errorf("invalid config set %q: %w", req.GetConfigSet(), err) 132 } 133 134 if len(req.GetFileHashes()) == 0 { 135 return errors.New("must provide non-empty file_hashes") 136 } 137 for i, fh := range req.GetFileHashes() { 138 if err := checkFileHash(fh); err != nil { 139 return fmt.Errorf("file_hash[%d]: %w", i, err) 140 } 141 } 142 return nil 143 } 144 145 var validSHA256Regexp = regexp.MustCompile(fmt.Sprintf(`^[0-9a-fA-F]{%d}$`, sha256.Size*2)) 146 147 func checkFileHash(fileHash *configpb.ValidateConfigsRequest_FileHash) error { 148 switch p := fileHash.GetPath(); { 149 case p == "": 150 return errors.New("path is empty") 151 case path.IsAbs(p): 152 return fmt.Errorf("path %q must not be absolute", p) 153 default: 154 for _, seg := range strings.Split(p, "/") { 155 if seg == "." || seg == ".." { 156 return fmt.Errorf("path %q must not contain '.' or '..' components", p) 157 } 158 } 159 } 160 161 switch sha256 := fileHash.GetSha256(); { 162 case sha256 == "": 163 return errors.New("sha256 is empty") 164 case !validSHA256Regexp.MatchString(sha256): 165 return fmt.Errorf("invalid sha256 hash %q", sha256) 166 } 167 return nil 168 } 169 170 // validationFile implements `validation.File` interface 171 type validationFile struct { 172 path string 173 gsPath gs.Path 174 } 175 176 // GetPath returns the relative config file path from the configuration root. 177 func (vf validationFile) GetPath() string { return vf.path } 178 179 // GetGSPath returns the Google Storage path to the content of the config. 180 func (vf validationFile) GetGSPath() gs.Path { return vf.gsPath } 181 182 // GetRawContent downloads the content from gcs and returns uncompressed 183 // content. 184 // 185 // The existing validation protocol explicitly asked the client to upload 186 // compressed config to Google Storage so we should expect the data returned 187 // from GS should be uncompressed fine. 188 func (vf validationFile) GetRawContent(ctx context.Context) ([]byte, error) { 189 bucket, object := vf.gsPath.Split() 190 compressed, err := clients.GetGsClient(ctx).Read(ctx, bucket, object, false) 191 if err != nil { 192 return nil, err 193 } 194 r, err := gzip.NewReader(bytes.NewBuffer(compressed)) 195 if err != nil { 196 return nil, fmt.Errorf("failed to create gzip reader: %w", err) 197 } 198 blob, err := io.ReadAll(r) 199 if err != nil { 200 _ = r.Close() 201 return nil, err 202 } 203 if err := r.Close(); err != nil { 204 return nil, fmt.Errorf("failed to close gzip reader: %w", err) 205 } 206 return blob, nil 207 } 208 209 // makeValidationFiles creates a `validationFile` for each file_hash in the 210 // validation request. 211 // 212 // LUCI Config derives corresponding Google Storage path from the file sha256. 213 // The bucket name is read from c.GSValidationBucket and the object name is in 214 // the format of: 215 // "users/$(hex_encoding(sha256(requesterID))[:4])/configs/sha256/$(file_hash.sha256)" 216 // The object name will also be prefixed with `common.GSValidationCfgFolder`. 217 // 218 // For example, if requester is "user:foo@example.com" and file sha256 is 219 // "abcdef0123456789", the corresponding GCS object is 220 // "validation/users/b68cda84/configs/sha256/abcdef0123456789" 221 func (c Configs) makeValidationFiles(requesterID identity.Identity, fhs []*configpb.ValidateConfigsRequest_FileHash) []validation.File { 222 ret := make([]validation.File, len(fhs)) 223 // use the hash of requester ID to avoid requester email show up in the gcs 224 // bucket. 225 h := sha256.New() 226 h.Write([]byte(requesterID)) 227 objectParts := []string{ 228 common.GSValidationCfgFolder, 229 "users", 230 hex.EncodeToString(h.Sum(nil)[:4]), 231 "configs", 232 "sha256", 233 } 234 for i, fh := range fhs { 235 ret[i] = validationFile{ 236 path: fh.GetPath(), 237 gsPath: gs.MakePath(c.GSValidationBucket, append(objectParts, fh.GetSha256())...), 238 } 239 } 240 return ret 241 } 242 243 // convertToFixInfo converts `validation.ExamineResult` to 244 // `configpb.BadValidationRequestFixInfo` 245 func convertToFixInfo(er *validation.ExamineResult) *configpb.BadValidationRequestFixInfo { 246 fixInfo := &configpb.BadValidationRequestFixInfo{} 247 for _, mf := range er.MissingFiles { 248 fixInfo.UploadFiles = append(fixInfo.UploadFiles, &configpb.BadValidationRequestFixInfo_UploadFile{ 249 Path: mf.File.GetPath(), 250 SignedUrl: mf.SignedURL, 251 MaxConfigSize: common.ConfigMaxSize, 252 }) 253 } 254 for _, uf := range er.UnvalidatableFiles { 255 fixInfo.UnvalidatableFiles = append(fixInfo.UnvalidatableFiles, uf.GetPath()) 256 } 257 return fixInfo 258 }