go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/config/server/cfgmodule/handler.go (about) 1 // Copyright 2017 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 cfgmodule 16 17 import ( 18 "bytes" 19 "context" 20 "encoding/json" 21 "fmt" 22 "net/http" 23 24 "github.com/klauspost/compress/gzip" 25 "golang.org/x/sync/errgroup" 26 "google.golang.org/grpc/codes" 27 "google.golang.org/grpc/status" 28 "google.golang.org/protobuf/types/known/emptypb" 29 30 "go.chromium.org/luci/auth/identity" 31 "go.chromium.org/luci/common/errors" 32 "go.chromium.org/luci/common/logging" 33 cfgpb "go.chromium.org/luci/common/proto/config" 34 "go.chromium.org/luci/config" 35 "go.chromium.org/luci/config/validation" 36 "go.chromium.org/luci/server/auth" 37 "go.chromium.org/luci/server/router" 38 ) 39 40 const ( 41 // paths for handlers 42 metadataPath = "/api/config/v1/metadata" 43 validationPath = "/api/config/v1/validate" 44 45 // Taken from 46 // https://chromium.googlesource.com/infra/luci/luci-py/+/3efc60daef6bf6669f9211f63e799db47a0478c0/appengine/components/components/config/endpoint.py 47 metaDataFormatVersion = "1.0" 48 adminGroup = "administrators" 49 ) 50 51 // ConsumerServer implements `cfgpb.Consumer` interface that will be called 52 // by LUCI Config. 53 type ConsumerServer struct { 54 cfgpb.UnimplementedConsumerServer 55 // Rules is a rule set to use for the config validation. 56 Rules *validation.RuleSet 57 // GetConfigServiceAccountFn returns a function that can fetch the service 58 // account of the LUCI Config service. It is used by ACL checking. 59 GetConfigServiceAccountFn func(context.Context) (string, error) 60 } 61 62 // GetMetadata implements cfgpb.Consumer.GetMetadata. 63 func (srv *ConsumerServer) GetMetadata(ctx context.Context, _ *emptypb.Empty) (*cfgpb.ServiceMetadata, error) { 64 if err := srv.checkCaller(ctx); err != nil { 65 return nil, err 66 } 67 patterns, err := srv.Rules.ConfigPatterns(ctx) 68 if err != nil { 69 return nil, status.Errorf(codes.Internal, "failed to collect the list of validation patterns: %s", err) 70 } 71 ret := &cfgpb.ServiceMetadata{} 72 if len(patterns) > 0 { 73 ret.ConfigPatterns = make([]*cfgpb.ConfigPattern, len(patterns)) 74 for i, pattern := range patterns { 75 ret.ConfigPatterns[i] = &cfgpb.ConfigPattern{ 76 ConfigSet: pattern.ConfigSet.String(), 77 Path: pattern.Path.String(), 78 } 79 } 80 } 81 return ret, nil 82 } 83 84 // ValidateConfigs implements cfgpb.Consumer.ValidateConfigs. 85 func (srv *ConsumerServer) ValidateConfigs(ctx context.Context, req *cfgpb.ValidateConfigsRequest) (*cfgpb.ValidationResult, error) { 86 if err := srv.checkCaller(ctx); err != nil { 87 return nil, err 88 } 89 if err := checkValidateInput(req); err != nil { 90 return nil, err 91 } 92 93 result := make([][]*cfgpb.ValidationResult_Message, len(req.GetFiles().GetFiles())) 94 eg, ectx := errgroup.WithContext(ctx) 95 eg.SetLimit(8) 96 for i, file := range req.GetFiles().GetFiles() { 97 i, file := i, file 98 eg.Go(func() error { 99 var content []byte 100 switch file.GetContent().(type) { 101 case *cfgpb.ValidateConfigsRequest_File_RawContent: 102 content = file.GetRawContent() 103 case *cfgpb.ValidateConfigsRequest_File_SignedUrl: 104 tr, err := auth.GetRPCTransport(ctx, auth.NoAuth) 105 if err != nil { 106 return fmt.Errorf("failed to get the RPC transport: %w", err) 107 } 108 if content, err = config.DownloadConfigFromSignedURL(ectx, &http.Client{Transport: tr}, file.GetSignedUrl()); err != nil { 109 return fmt.Errorf("failed to download file %s from the signed url: %w", file.Path, err) 110 } 111 default: 112 panic(fmt.Errorf("unrecognized file content type: %T", file.GetContent())) 113 } 114 var err error 115 result[i], err = srv.validateOneFile(ectx, req.GetConfigSet(), file.GetPath(), content) 116 return err 117 }) 118 } 119 120 if err := eg.Wait(); err != nil { 121 return nil, status.Errorf(codes.Internal, "encounter internal error: %s", err) 122 } 123 124 // Flatten the messages 125 ret := &cfgpb.ValidationResult{} 126 for _, msgs := range result { 127 ret.Messages = append(ret.Messages, msgs...) 128 } 129 return ret, nil 130 } 131 132 // only LUCI Config and identity in admin group is allowed to call. 133 func (srv *ConsumerServer) checkCaller(ctx context.Context) error { 134 configServiceAccount, err := srv.GetConfigServiceAccountFn(ctx) 135 if err != nil { 136 logging.Errorf(ctx, "Failed to get LUCI Config service account: %s", err) 137 return status.Errorf(codes.Internal, "failed to get LUCI Config service account") 138 } 139 caller := auth.CurrentIdentity(ctx) 140 if caller.Kind() == identity.User && caller.Value() == configServiceAccount { 141 return nil 142 } 143 switch admin, err := auth.IsMember(ctx, adminGroup); { 144 case err != nil: 145 logging.Errorf(ctx, "Failed to check ACL: %s", err) 146 return status.Errorf(codes.Internal, "failed to check ACL") 147 case admin: 148 return nil 149 } 150 return status.Errorf(codes.PermissionDenied, "%q is not authorized", caller) 151 } 152 153 func checkValidateInput(req *cfgpb.ValidateConfigsRequest) error { 154 switch { 155 case req.GetConfigSet() == "": 156 return status.Errorf(codes.InvalidArgument, "must specify the config_set of the file to validate") 157 case len(req.GetFiles().GetFiles()) == 0: 158 return status.Errorf(codes.InvalidArgument, "must provide at least 1 file to validate") 159 } 160 for i, file := range req.GetFiles().GetFiles() { 161 if file.GetPath() == "" { 162 return status.Errorf(codes.InvalidArgument, "must specify path for file[%d]", i) 163 } 164 if file.GetRawContent() == nil && file.GetSignedUrl() == "" { 165 return status.Errorf(codes.InvalidArgument, "must either provide raw_content or signed_url for file %q", file.GetPath()) 166 } 167 } 168 return nil 169 } 170 171 func (srv *ConsumerServer) validateOneFile(ctx context.Context, configSet, path string, content []byte) ([]*cfgpb.ValidationResult_Message, error) { 172 vc := &validation.Context{Context: ctx} 173 vc.SetFile(path) 174 if err := srv.Rules.ValidateConfig(vc, configSet, path, content); err != nil { 175 return nil, err 176 } 177 var vErr *validation.Error 178 switch err := vc.Finalize(); { 179 case errors.As(err, &vErr): 180 return vErr.ToValidationResultMsgs(ctx), nil 181 case err != nil: 182 return []*cfgpb.ValidationResult_Message{ 183 { 184 Path: path, 185 Severity: cfgpb.ValidationResult_ERROR, 186 Text: err.Error(), 187 }, 188 }, nil 189 default: 190 return nil, nil 191 } 192 } 193 194 // InstallHandlers installs the metadata and validation handlers that use 195 // the given validation rules. 196 // 197 // It does not implement any authentication checks, thus the passed in 198 // router.MiddlewareChain should implement any necessary authentication checks. 199 // 200 // Deprecated: The handlers are called by the legacy LUCI Config service. The 201 // new LUCI Config service will make request to `cfgpb.Consumer` prpc service 202 // instead. See `consumerServer`. 203 func InstallHandlers(r *router.Router, base router.MiddlewareChain, rules *validation.RuleSet) { 204 r.GET(metadataPath, base, metadataRequestHandler(rules)) 205 r.POST(validationPath, base, validationRequestHandler(rules)) 206 } 207 208 func badRequestStatus(c context.Context, w http.ResponseWriter, msg string, err error) { 209 if err != nil { 210 logging.WithError(err).Warningf(c, "%s", msg) 211 } else { 212 logging.Warningf(c, "%s", msg) 213 } 214 w.WriteHeader(http.StatusBadRequest) 215 w.Write([]byte(msg)) 216 } 217 218 func internalErrStatus(c context.Context, w http.ResponseWriter, msg string, err error) { 219 logging.WithError(err).Errorf(c, "%s", msg) 220 w.WriteHeader(http.StatusInternalServerError) 221 w.Write([]byte(msg)) 222 } 223 224 // validationRequestHandler handles the validation request from luci-config and 225 // responds with the corresponding results. 226 func validationRequestHandler(rules *validation.RuleSet) router.Handler { 227 return func(ctx *router.Context) { 228 c, w, r := ctx.Request.Context(), ctx.Writer, ctx.Request 229 230 raw := r.Body 231 if r.Header.Get("Content-Encoding") == "gzip" { 232 logging.Infof(c, "The request is gzip compressed") 233 var err error 234 if raw, err = gzip.NewReader(r.Body); err != nil { 235 badRequestStatus(c, w, "Failed to start decompressing gzip request body", err) 236 return 237 } 238 defer raw.Close() 239 } 240 241 var reqBody cfgpb.ValidationRequestMessage 242 switch err := json.NewDecoder(raw).Decode(&reqBody); { 243 case err != nil: 244 badRequestStatus(c, w, "Validation: error decoding request body", err) 245 return 246 case reqBody.GetConfigSet() == "": 247 badRequestStatus(c, w, "Must specify the config_set of the file to validate", nil) 248 return 249 case reqBody.GetPath() == "": 250 badRequestStatus(c, w, "Must specify the path of the file to validate", nil) 251 return 252 } 253 254 vc := &validation.Context{Context: c} 255 vc.SetFile(reqBody.GetPath()) 256 err := rules.ValidateConfig(vc, reqBody.GetConfigSet(), reqBody.GetPath(), reqBody.GetContent()) 257 if err != nil { 258 internalErrStatus(c, w, "Validation: transient failure", err) 259 return 260 } 261 262 var errors errors.MultiError 263 verdict := vc.Finalize() 264 if verr, _ := verdict.(*validation.Error); verr != nil { 265 errors = verr.Errors 266 } else if verdict != nil { 267 errors = append(errors, verdict) 268 } 269 270 w.Header().Set("Content-Type", "application/json") 271 var msgList []*cfgpb.ValidationResponseMessage_Message 272 if len(errors) == 0 { 273 logging.Infof(c, "No validation errors") 274 } else { 275 var errorBuffer bytes.Buffer 276 for _, error := range errors { 277 // validation.Context supports just 2 severities now, 278 // but defensively default to ERROR level in unexpected cases. 279 msgSeverity := cfgpb.ValidationResponseMessage_ERROR 280 switch severity, ok := validation.SeverityTag.In(error); { 281 case !ok: 282 logging.Errorf(c, "unset validation.Severity in %s", error) 283 case severity == validation.Warning: 284 msgSeverity = cfgpb.ValidationResponseMessage_WARNING 285 case severity != validation.Blocking: 286 logging.Errorf(c, "unrecognized validation.Severity %d in %s", severity, error) 287 } 288 289 err := error.Error() 290 msgList = append(msgList, &cfgpb.ValidationResponseMessage_Message{ 291 Severity: msgSeverity, 292 Text: err, 293 }) 294 errorBuffer.WriteString("\n " + err) 295 } 296 logging.Warningf(c, "Validation errors%s", errorBuffer.String()) 297 } 298 if err := json.NewEncoder(w).Encode(cfgpb.ValidationResponseMessage{Messages: msgList}); err != nil { 299 internalErrStatus(c, w, "Validation: failed to JSON encode output", err) 300 } 301 } 302 } 303 304 // metadataRequestHandler handles the metadata request from luci-config and 305 // responds with the necessary metadata defined by the given Validator. 306 func metadataRequestHandler(rules *validation.RuleSet) router.Handler { 307 return func(ctx *router.Context) { 308 c, w := ctx.Request.Context(), ctx.Writer 309 310 patterns, err := rules.ConfigPatterns(c) 311 if err != nil { 312 internalErrStatus(c, w, "Metadata: failed to collect the list of validation patterns", err) 313 return 314 } 315 316 meta := cfgpb.ServiceDynamicMetadata{ 317 Version: metaDataFormatVersion, 318 SupportsGzipCompression: true, 319 Validation: &cfgpb.Validator{ 320 Url: fmt.Sprintf("https://%s%s", ctx.Request.Host, validationPath), 321 }, 322 } 323 for _, p := range patterns { 324 meta.Validation.Patterns = append(meta.Validation.Patterns, &cfgpb.ConfigPattern{ 325 ConfigSet: p.ConfigSet.String(), 326 Path: p.Path.String(), 327 }) 328 } 329 330 w.Header().Set("Content-Type", "application/json") 331 if err := json.NewEncoder(w).Encode(&meta); err != nil { 332 internalErrStatus(c, w, "Metadata: failed to JSON encode output", err) 333 } 334 } 335 }