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  }