go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/rpc/rules.go (about) 1 // Copyright 2022 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 "context" 19 "fmt" 20 "regexp" 21 "time" 22 23 "google.golang.org/grpc/codes" 24 "google.golang.org/grpc/status" 25 "google.golang.org/protobuf/types/known/timestamppb" 26 27 "go.chromium.org/luci/common/errors" 28 "go.chromium.org/luci/common/logging" 29 "go.chromium.org/luci/grpc/appstatus" 30 "go.chromium.org/luci/server/auth" 31 "go.chromium.org/luci/server/span" 32 33 "go.chromium.org/luci/analysis/internal/bugs" 34 bugspb "go.chromium.org/luci/analysis/internal/bugs/proto" 35 "go.chromium.org/luci/analysis/internal/clustering" 36 "go.chromium.org/luci/analysis/internal/clustering/rules" 37 "go.chromium.org/luci/analysis/internal/config/compiledcfg" 38 "go.chromium.org/luci/analysis/internal/perms" 39 configpb "go.chromium.org/luci/analysis/proto/config" 40 pb "go.chromium.org/luci/analysis/proto/v1" 41 ) 42 43 // Rules implements pb.RulesServer. 44 type rulesServer struct { 45 } 46 47 // NewRulesSever returns a new pb.RulesServer. 48 func NewRulesSever() pb.RulesServer { 49 return &pb.DecoratedRules{ 50 Prelude: checkAllowedPrelude, 51 Service: &rulesServer{}, 52 Postlude: gRPCifyAndLogPostlude, 53 } 54 } 55 56 // Retrieves a rule. 57 func (*rulesServer) Get(ctx context.Context, req *pb.GetRuleRequest) (*pb.Rule, error) { 58 project, ruleID, err := parseRuleName(req.Name) 59 if err != nil { 60 return nil, invalidArgumentError(err) 61 } 62 if err := perms.VerifyProjectPermissions(ctx, project, perms.PermGetRule); err != nil { 63 return nil, err 64 } 65 ruleMask, err := ruleFieldAccess(ctx, project) 66 if err != nil { 67 return nil, err 68 } 69 70 cfg, err := readProjectConfig(ctx, project) 71 if err != nil { 72 return nil, err 73 } 74 75 r, err := rules.Read(span.Single(ctx), project, ruleID) 76 if err != nil { 77 if err == rules.NotExistsErr { 78 return nil, appstatus.Error(codes.NotFound, "rule does not exist") 79 } 80 // This will result in an internal error being reported to the caller. 81 return nil, errors.Annotate(err, "reading rule %s", ruleID).Err() 82 } 83 return createRulePB(r, cfg.Config, ruleMask), nil 84 } 85 86 // ruleMask captures the fields the caller has access to see. 87 type ruleMask struct { 88 // Include the definition of the rule. 89 // Guarded by analysis.rules.getDefinition permission. 90 IncludeDefinition bool 91 // Include the email address of users who created/modified the rule. 92 // Limited to Googlers only. 93 IncludeAuditUsers bool 94 } 95 96 // ruleFieldAccess checks the caller's access to rule fields in the given 97 // project and returns a ruleMask corresponding to the fields they are 98 // allowed to see. 99 func ruleFieldAccess(ctx context.Context, project string) (ruleMask, error) { 100 var result ruleMask 101 var err error 102 result.IncludeDefinition, err = perms.HasProjectPermission(ctx, project, perms.PermGetRuleDefinition) 103 if err != nil { 104 return ruleMask{}, errors.Annotate(err, "determining access to rule definition").Err() 105 } 106 result.IncludeAuditUsers, err = auth.IsMember(ctx, auditUsersAccessGroup) 107 if err != nil { 108 return ruleMask{}, errors.Annotate(err, "determining access to read created/last modified users").Err() 109 } 110 return result, nil 111 } 112 113 // Lists rules. 114 func (*rulesServer) List(ctx context.Context, req *pb.ListRulesRequest) (*pb.ListRulesResponse, error) { 115 project, err := parseProjectName(req.Parent) 116 if err != nil { 117 return nil, invalidArgumentError(err) 118 } 119 if err := perms.VerifyProjectPermissions(ctx, project, perms.PermListRules); err != nil { 120 return nil, err 121 } 122 ruleMask, err := ruleFieldAccess(ctx, project) 123 if err != nil { 124 return nil, err 125 } 126 127 cfg, err := readProjectConfig(ctx, project) 128 if err != nil { 129 return nil, err 130 } 131 132 // TODO: Update to read all rules (not just active), and implement pagination. 133 rs, err := rules.ReadActive(span.Single(ctx), project) 134 if err != nil { 135 // GRPCifyAndLog will log this, and report an internal error. 136 return nil, errors.Annotate(err, "reading rules").Err() 137 } 138 139 rpbs := make([]*pb.Rule, 0, len(rs)) 140 for _, r := range rs { 141 rpbs = append(rpbs, createRulePB(r, cfg.Config, ruleMask)) 142 } 143 response := &pb.ListRulesResponse{ 144 Rules: rpbs, 145 } 146 return response, nil 147 } 148 149 // Creates a new rule. 150 func (*rulesServer) Create(ctx context.Context, req *pb.CreateRuleRequest) (*pb.Rule, error) { 151 project, err := parseProjectName(req.Parent) 152 if err != nil { 153 return nil, invalidArgumentError(err) 154 } 155 if err := perms.VerifyProjectPermissions(ctx, project, perms.PermCreateRule, perms.PermGetRuleDefinition); err != nil { 156 return nil, err 157 } 158 ruleMask, err := ruleFieldAccess(ctx, project) 159 if err != nil { 160 return nil, err 161 } 162 163 cfg, err := readProjectConfig(ctx, project) 164 if err != nil { 165 return nil, err 166 } 167 168 ruleID, err := rules.GenerateID() 169 if err != nil { 170 return nil, errors.Annotate(err, "generating Rule ID").Err() 171 } 172 user := auth.CurrentUser(ctx).Email 173 174 r := &rules.Entry{ 175 Project: project, 176 RuleID: ruleID, 177 RuleDefinition: req.Rule.GetRuleDefinition(), 178 BugID: bugs.BugID{ 179 System: req.Rule.Bug.GetSystem(), 180 ID: req.Rule.Bug.GetId(), 181 }, 182 IsActive: req.Rule.GetIsActive(), 183 IsManagingBug: req.Rule.GetIsManagingBug(), 184 IsManagingBugPriority: req.Rule.GetIsManagingBugPriority(), 185 SourceCluster: clustering.ClusterID{ 186 Algorithm: req.Rule.SourceCluster.GetAlgorithm(), 187 ID: req.Rule.SourceCluster.GetId(), 188 }, 189 BugManagementState: &bugspb.BugManagementState{}, 190 } 191 192 if err := validateBugAgainstConfig(cfg, r.BugID); err != nil { 193 return nil, invalidArgumentError(err) 194 } 195 196 commitTime, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error { 197 // Verify the bug is not used by another rule in this project. 198 bugRules, err := rules.ReadByBug(ctx, r.BugID) 199 if err != nil { 200 return err 201 } 202 for _, otherRule := range bugRules { 203 if otherRule.IsManagingBug { 204 // Avoid conflicts by silently making the bug not managed 205 // by this rule if there is another rule managing it. 206 // Note: this validation implicitly discloses the existence 207 // of rules in projects other than those the user may have 208 // access to. 209 r.IsManagingBug = false 210 } 211 if otherRule.Project == r.Project { 212 return invalidArgumentError(fmt.Errorf("bug already used by a rule in the same project (%s/%s)", otherRule.Project, otherRule.RuleID)) 213 } 214 } 215 216 ms, err := rules.Create(r, user) 217 if err != nil { 218 return invalidArgumentError(err) 219 } 220 span.BufferWrite(ctx, ms) 221 return nil 222 }) 223 if err != nil { 224 return nil, err 225 } 226 r.CreateTime = commitTime.In(time.UTC) 227 r.CreateUser = user 228 r.LastAuditableUpdateTime = commitTime.In(time.UTC) 229 r.LastAuditableUpdateUser = user 230 r.LastUpdateTime = commitTime.In(time.UTC) 231 r.PredicateLastUpdateTime = commitTime.In(time.UTC) 232 r.IsManagingBugPriorityLastUpdateTime = commitTime.In(time.UTC) 233 234 // Log rule changes to provide a way of recovering old system state 235 // if malicious or unintended updates occur. 236 logRuleCreate(ctx, r) 237 238 return createRulePB(r, cfg.Config, ruleMask), nil 239 } 240 241 func logRuleCreate(ctx context.Context, rule *rules.Entry) { 242 logging.Infof(ctx, "Rule created (%s/%s): %s", rule.Project, rule.RuleID, formatRule(rule)) 243 } 244 245 // Updates a rule. 246 func (*rulesServer) Update(ctx context.Context, req *pb.UpdateRuleRequest) (*pb.Rule, error) { 247 project, ruleID, err := parseRuleName(req.Rule.GetName()) 248 if err != nil { 249 return nil, invalidArgumentError(err) 250 } 251 if err := perms.VerifyProjectPermissions(ctx, project, perms.PermUpdateRule, perms.PermGetRuleDefinition); err != nil { 252 return nil, err 253 } 254 ruleMask, err := ruleFieldAccess(ctx, project) 255 if err != nil { 256 return nil, err 257 } 258 259 cfg, err := readProjectConfig(ctx, project) 260 if err != nil { 261 return nil, err 262 } 263 264 user := auth.CurrentUser(ctx).Email 265 266 var predicateUpdated bool 267 var managingBugPriorityUpdated bool 268 var originalRule *rules.Entry 269 var updatedRule *rules.Entry 270 f := func(ctx context.Context) error { 271 rule, err := rules.Read(ctx, project, ruleID) 272 if err != nil { 273 if err == rules.NotExistsErr { 274 return appstatus.Error(codes.NotFound, "rule does not exist") 275 } 276 // This will result in an internal error being reported to the 277 // caller. 278 return errors.Annotate(err, "read rule").Err() 279 } 280 originalRule = &rules.Entry{} 281 *originalRule = *rule 282 283 if req.Etag != "" && !isETagMatching(rule, req.Etag) { 284 // Attach a codes.Aborted appstatus to a vanilla error to avoid 285 // ReadWriteTransaction interpreting this case for a scenario 286 // in which it should retry the transaction. 287 err := errors.New("etag mismatch") 288 return appstatus.Attach(err, status.New(codes.Aborted, "the rule was modified since it was last read; the update was not applied.")) 289 } 290 291 // If we are updating the RuleDefinition or IsActive field. 292 updatePredicate := false 293 294 // If we are updating the Bug field 295 updatingBug := false 296 297 // If we are updating the IsManagingBug field. 298 updatingManaged := false 299 300 // If we are updating the IsManagingBugPriority field. 301 updatingIsManagingBugPriority := false 302 303 // Tracks if the caller explicitly requested .IsManagingBug = true, even 304 // if this is a no-op. 305 requestedManagedTrue := false 306 307 for _, path := range req.UpdateMask.Paths { 308 // Only limited fields may be modified by the client. 309 switch path { 310 case "rule_definition": 311 if rule.RuleDefinition != req.Rule.RuleDefinition { 312 rule.RuleDefinition = req.Rule.RuleDefinition 313 updatePredicate = true 314 } 315 case "bug": 316 bugID := bugs.BugID{ 317 System: req.Rule.Bug.GetSystem(), 318 ID: req.Rule.Bug.GetId(), 319 } 320 if err := validateBugAgainstConfig(cfg, bugID); err != nil { 321 return invalidArgumentError(err) 322 } 323 if rule.BugID != bugID { 324 updatingBug = true // Triggers validation. 325 rule.BugID = bugID 326 327 // Changing the associated bug requires us to reset flags 328 // tracking notifications sent to the associated bug. 329 rule.BugManagementState.RuleAssociationNotified = false 330 if rule.BugManagementState.PolicyState != nil { 331 for _, policyState := range rule.BugManagementState.PolicyState { 332 policyState.ActivationNotified = false 333 } 334 } 335 } 336 case "is_active": 337 if rule.IsActive != req.Rule.IsActive { 338 rule.IsActive = req.Rule.IsActive 339 updatePredicate = true 340 } 341 case "is_managing_bug": 342 if req.Rule.IsManagingBug { 343 requestedManagedTrue = true 344 } 345 if rule.IsManagingBug != req.Rule.IsManagingBug { 346 updatingManaged = true // Triggers validation. 347 rule.IsManagingBug = req.Rule.IsManagingBug 348 } 349 case "is_managing_bug_priority": 350 if rule.IsManagingBugPriority != req.Rule.IsManagingBugPriority { 351 updatingIsManagingBugPriority = true 352 rule.IsManagingBugPriority = req.Rule.IsManagingBugPriority 353 } 354 default: 355 return invalidArgumentError(fmt.Errorf("unsupported field mask: %s", path)) 356 } 357 } 358 359 if updatingBug || updatingManaged { 360 // Verify the new bug is not used by another rule in the 361 // same project, and that there are not multiple rules 362 // managing the same bug. 363 bugRules, err := rules.ReadByBug(ctx, rule.BugID) 364 if err != nil { 365 // This will result in an internal error being reported 366 // to the caller. 367 return err 368 } 369 for _, otherRule := range bugRules { 370 if otherRule.Project == project && otherRule.RuleID != ruleID { 371 return invalidArgumentError(fmt.Errorf("bug already used by a rule in the same project (%s/%s)", otherRule.Project, otherRule.RuleID)) 372 } 373 } 374 for _, otherRule := range bugRules { 375 if otherRule.Project != project && otherRule.IsManagingBug { 376 if requestedManagedTrue { 377 // The caller explicitly requested an update of 378 // IsManagingBug to true, but we cannot do this. 379 return invalidArgumentError(fmt.Errorf("bug already managed by a rule in another project (%s/%s)", otherRule.Project, otherRule.RuleID)) 380 } 381 // If only changing the bug, avoid conflicts by silently 382 // making the bug not managed by this rule if there is 383 // another rule managing it. 384 // Note: this step implicitly discloses the existence 385 // of rules in projects other than those the user may have 386 // access to. 387 rule.IsManagingBug = false 388 } 389 } 390 } 391 392 ms, err := rules.Update(rule, rules.UpdateOptions{ 393 IsAuditableUpdate: true, 394 PredicateUpdated: updatePredicate, 395 IsManagingBugPriorityUpdated: updatingIsManagingBugPriority, 396 }, user) 397 if err != nil { 398 return invalidArgumentError(err) 399 } 400 span.BufferWrite(ctx, ms) 401 updatedRule = rule 402 predicateUpdated = updatePredicate 403 managingBugPriorityUpdated = updatingIsManagingBugPriority 404 return nil 405 } 406 commitTime, err := span.ReadWriteTransaction(ctx, f) 407 if err != nil { 408 return nil, err 409 } 410 updatedRule.LastAuditableUpdateTime = commitTime.In(time.UTC) 411 updatedRule.LastAuditableUpdateUser = user 412 updatedRule.LastUpdateTime = commitTime.In(time.UTC) 413 if predicateUpdated { 414 updatedRule.PredicateLastUpdateTime = commitTime.In(time.UTC) 415 } 416 if managingBugPriorityUpdated { 417 updatedRule.IsManagingBugPriorityLastUpdateTime = commitTime.In(time.UTC) 418 } 419 // Log rule changes to provide a way of recovering old system state 420 // if malicious or unintended updates occur. 421 logRuleUpdate(ctx, originalRule, updatedRule) 422 423 return createRulePB(updatedRule, cfg.Config, ruleMask), nil 424 } 425 426 func logRuleUpdate(ctx context.Context, old *rules.Entry, new *rules.Entry) { 427 logging.Infof(ctx, "Rule updated (%s/%s): from %s to %s", old.Project, old.RuleID, formatRule(old), formatRule(new)) 428 } 429 430 func formatRule(r *rules.Entry) string { 431 return fmt.Sprintf("{\n"+ 432 "\tRuleDefinition: %q,\n"+ 433 "\tBugID: %q,\n"+ 434 "\tIsActive: %v,\n"+ 435 "\tIsManagingBug: %v,\n"+ 436 "\tIsManagingBugPriority: %v,\n"+ 437 "\tSourceCluster: %q\n"+ 438 "\tLastAuditableUpdate: %q\n"+ 439 "\tLastUpdated: %q\n"+ 440 "}", r.RuleDefinition, r.BugID, r.IsActive, r.IsManagingBug, 441 r.IsManagingBugPriority, r.SourceCluster, 442 r.LastAuditableUpdateTime.Format(time.RFC3339Nano), 443 r.LastUpdateTime.Format(time.RFC3339Nano)) 444 } 445 446 // LookupBug looks up the rule associated with the given bug. 447 func (*rulesServer) LookupBug(ctx context.Context, req *pb.LookupBugRequest) (*pb.LookupBugResponse, error) { 448 bug := bugs.BugID{ 449 System: req.System, 450 ID: req.Id, 451 } 452 if err := bug.Validate(); err != nil { 453 return nil, invalidArgumentError(err) 454 } 455 rules, err := rules.ReadByBug(span.Single(ctx), bug) 456 if err != nil { 457 // This will result in an internal error being reported to the caller. 458 return nil, errors.Annotate(err, "reading rule by bug %s:%s", bug.System, bug.ID).Err() 459 } 460 ruleNames := make([]string, 0, len(rules)) 461 for _, rule := range rules { 462 allowed, err := perms.HasProjectPermission(ctx, rule.Project, perms.PermListRules) 463 if err != nil { 464 return nil, err 465 } 466 if allowed { 467 ruleNames = append(ruleNames, ruleName(rule.Project, rule.RuleID)) 468 } 469 } 470 return &pb.LookupBugResponse{ 471 Rules: ruleNames, 472 }, nil 473 } 474 475 func createRulePB(r *rules.Entry, cfg *configpb.ProjectConfig, mask ruleMask) *pb.Rule { 476 definition := "" 477 if mask.IncludeDefinition { 478 definition = r.RuleDefinition 479 } 480 creationUser := "" 481 lastAuditableUpdateUser := "" 482 if mask.IncludeAuditUsers { 483 creationUser = r.CreateUser 484 lastAuditableUpdateUser = r.LastAuditableUpdateUser 485 } 486 return &pb.Rule{ 487 Name: ruleName(r.Project, r.RuleID), 488 Project: r.Project, 489 RuleId: r.RuleID, 490 RuleDefinition: definition, 491 Bug: createAssociatedBugPB(r.BugID, cfg), 492 IsActive: r.IsActive, 493 IsManagingBug: r.IsManagingBug, 494 IsManagingBugPriority: r.IsManagingBugPriority, 495 IsManagingBugPriorityLastUpdateTime: timestamppb.New(r.IsManagingBugPriorityLastUpdateTime), 496 SourceCluster: &pb.ClusterId{ 497 Algorithm: r.SourceCluster.Algorithm, 498 Id: r.SourceCluster.ID, 499 }, 500 BugManagementState: rules.ToExternalBugManagementStatePB(r.BugManagementState), 501 CreateTime: timestamppb.New(r.CreateTime), 502 CreateUser: creationUser, 503 LastAuditableUpdateTime: timestamppb.New(r.LastAuditableUpdateTime), 504 LastAuditableUpdateUser: lastAuditableUpdateUser, 505 LastUpdateTime: timestamppb.New(r.LastUpdateTime), 506 PredicateLastUpdateTime: timestamppb.New(r.PredicateLastUpdateTime), 507 Etag: ruleETag(r, mask), 508 } 509 } 510 511 // ruleETag returns the HTTP ETag for the given rule. 512 func ruleETag(rule *rules.Entry, mask ruleMask) string { 513 definitionFilter := "" 514 if mask.IncludeDefinition { 515 definitionFilter = "+d" 516 } 517 userFilter := "" 518 if mask.IncludeAuditUsers { 519 userFilter = "+u" 520 } 521 // Encode whether the definition and user were included, 522 // so that if the user is granted access to either of 523 // these fields, the browser cache is invalidated. 524 return fmt.Sprintf(`W/"%s%s/%s"`, definitionFilter, userFilter, rule.LastUpdateTime.UTC().Format(time.RFC3339Nano)) 525 } 526 527 // etagRegexp extracts the rule last modified timestamp from a rule ETag. 528 var etagRegexp = regexp.MustCompile(`^W/"(?:\+[ud])*/(.*)"$`) 529 530 // isETagMatching determines if the Etag is consistent with the specified 531 // Rule version. 532 func isETagMatching(rule *rules.Entry, etag string) bool { 533 m := etagRegexp.FindStringSubmatch(etag) 534 if len(m) < 2 { 535 return false 536 } 537 return m[1] == rule.LastUpdateTime.UTC().Format(time.RFC3339Nano) 538 } 539 540 // validateBugAgainstConfig validates the specified bug is consistent with 541 // the project configuration. 542 func validateBugAgainstConfig(cfg *compiledcfg.ProjectConfig, bug bugs.BugID) error { 543 switch bug.System { 544 case bugs.MonorailSystem: 545 project, _, err := bug.MonorailProjectAndID() 546 if err != nil { 547 return err 548 } 549 monorailCfg := cfg.Config.BugManagement.GetMonorail() 550 if monorailCfg == nil { 551 return fmt.Errorf("monorail bug system not enabled for this LUCI project") 552 } 553 if project != monorailCfg.Project { 554 return fmt.Errorf("bug not in expected monorail project (%s)", monorailCfg.Project) 555 } 556 case bugs.BuganizerSystem: 557 // Buganizer bugs are permitted for all LUCI Analysis projects. 558 default: 559 return fmt.Errorf("unsupported bug system: %s", bug.System) 560 } 561 return nil 562 }