go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/acls/run_create.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 acls 16 17 import ( 18 "context" 19 "fmt" 20 "strings" 21 22 "go.chromium.org/luci/auth/identity" 23 "go.chromium.org/luci/common/errors" 24 "go.chromium.org/luci/common/logging" 25 gerritpb "go.chromium.org/luci/common/proto/gerrit" 26 "go.chromium.org/luci/server/auth" 27 28 "go.chromium.org/luci/cv/internal/changelist" 29 "go.chromium.org/luci/cv/internal/common" 30 "go.chromium.org/luci/cv/internal/configs/prjcfg" 31 "go.chromium.org/luci/cv/internal/run" 32 33 cfgpb "go.chromium.org/luci/cv/api/config/v2" 34 ) 35 36 const ( 37 okButDueToOthers = "CV cannot start a Run due to errors in the following CL(s)." 38 ownerNotCommitter = "CV cannot start a Run for `%s` because the user is not a committer." 39 ownerNotDryRunner = "CV cannot start a Run for `%s` because the user is not a dry-runner." 40 notOwnerNotCommitter = "CV cannot start a Run for `%s` because the user is neither the CL owner nor a committer." 41 notOwnerNotCommitterNotDryRunner = "CV cannot start a Run for `%s` because the user is neither the CL owner nor a committer nor a dry-runner." 42 43 notSubmittable = "CV cannot start a Run because this CL is not submittable. " + submitReqHint 44 notSubmittableWithReqs = "CV cannot start a Run because this CL is %s. " + submitReqHint 45 notSubmittableSuspicious = notSubmittable + " " + 46 "However, all submit requirements appear to be satisfied. " + 47 "It's likely caused by an issue in Gerrit or Gerrit configuration. " + 48 "Please contact your Git admin." 49 submitReqHint = "Please hover over the corresponding entry in the Submit Requirements section to check what is missing." 50 51 untrustedDeps = "" + 52 "CV cannot start a Run because of the following dependencies. " + 53 "They must be submittable (please check the submit requirement) because " + 54 "their owners are not committers. " + 55 "Alternatively, you can ask the owner of this CL to trigger a dry-run." 56 untrustedDepsTrustDryRunnerDeps = "" + 57 "CV cannot start a Run because of the following dependencies. " + 58 "They must be submittable (please check the submit requirement) because " + 59 "their owners are not committers or dry-runners. " + 60 "Alternatively, you can ask the owner of this CL to trigger a dry-run." 61 untrustedDepsSuspicious = "" + 62 "However, some or all of the dependencies appear to satisfy all the requirements. " + 63 "It's likely caused by an issue in Gerrit or Gerrit configuration. " + 64 "Please contact your Git admin." 65 ) 66 67 // runCreateChecker holds the evaluation results of a CL Run, and checks 68 // if the Run can be created. 69 type runCreateChecker struct { 70 cl *changelist.CL 71 runMode run.Mode 72 runModeDef *cfgpb.Mode // if mode is not standard mode in CV 73 allowOwnerIfSubmittable cfgpb.Verifiers_GerritCQAbility_CQAction 74 trustDryRunnerDeps bool 75 allowNonOwnerDryRunner bool 76 commGroups []string // committer groups 77 dryGroups []string // dry-runner groups 78 newPatchsetGroups []string // new patchset run groups 79 80 owner identity.Identity // the CL owner 81 triggerer identity.Identity // the Run triggerer 82 triggererEmail string // email of the Run triggerer 83 submittable bool // if the CL is submittable in Gerrit 84 submitted bool // if the CL has been submitted in Gerrit 85 depsToExamine common.CLIDs // deps that are possibly untrusted. 86 trustedDeps common.CLIDsSet // deps that have been proven to be trustable. 87 } 88 89 func (ck runCreateChecker) canTrustDeps(ctx context.Context) (evalResult, error) { 90 if len(ck.depsToExamine) == 0 { 91 return yes, nil 92 } 93 deps := make([]*changelist.CL, 0, len(ck.depsToExamine)) 94 for _, id := range ck.depsToExamine { 95 if !ck.trustedDeps.Has(id) { 96 deps = append(deps, &changelist.CL{ID: id}) 97 } 98 } 99 if len(deps) == 0 { 100 return yes, nil 101 } 102 103 // Fetch the CL entity of the deps and examine if they are trustable. 104 // CV never removes CL entities. Hence, this handles transient and 105 // datastore.ErrNoSuchEntity in the same way. 106 if err := changelist.LoadCLs(ctx, deps); err != nil { 107 return no, err 108 } 109 untrusted := deps[:0] 110 for _, d := range deps { 111 // Dep is trusted, if 112 // - it has been submitted, OR 113 // - it is submittable, OR 114 // - the owner is a committer, OR 115 // - config enables trust_dry_runner_deps and the owner is a dry runner 116 switch submitted, err := d.Snapshot.IsSubmitted(); { 117 case err != nil: 118 return no, errors.Annotate(err, "dep-CL(%d)", d.ID).Err() 119 case submitted: 120 ck.trustedDeps.Add(d.ID) 121 continue 122 } 123 switch submittable, err := d.Snapshot.IsSubmittable(); { 124 case err != nil: 125 return no, errors.Annotate(err, "dep-CL(%d)", d.ID).Err() 126 case submittable: 127 ck.trustedDeps.Add(d.ID) 128 continue 129 } 130 131 depOwner, err := d.Snapshot.OwnerIdentity() 132 if err != nil { 133 return no, errors.Annotate(err, "dep-CL(%d)", d.ID).Err() 134 } 135 136 switch isCommitter, err := ck.isCommitter(ctx, depOwner); { 137 case err != nil: 138 return no, errors.Annotate(err, 139 "dep-CL(%d): checking if owner %q is a committer", d.ID, depOwner).Err() 140 case isCommitter: 141 ck.trustedDeps.Add(d.ID) 142 continue 143 } 144 145 if ck.trustDryRunnerDeps { 146 switch isDryRunner, err := ck.isDryRunner(ctx, depOwner); { 147 case err != nil: 148 return no, errors.Annotate(err, 149 "dep-CL(%d): checking if owner %q is a dry-runner", d.ID, depOwner).Err() 150 case isDryRunner: 151 ck.trustedDeps.Add(d.ID) 152 continue 153 } 154 } 155 156 untrusted = append(untrusted, d) 157 } 158 if len(untrusted) == 0 { 159 return yes, nil 160 } 161 return noWithReason(untrustedDepsReason(ctx, untrusted, ck.trustDryRunnerDeps)), nil 162 } 163 164 func (ck runCreateChecker) canCreateRun(ctx context.Context) (evalResult, error) { 165 switch ck.runMode { 166 case run.FullRun: 167 return ck.canCreateFullRun(ctx) 168 case run.DryRun: 169 return ck.canCreateDryRun(ctx) 170 case run.NewPatchsetRun: 171 return ck.canCreateNewPatchsetRun(ctx) 172 default: 173 // TODO(yiwzhang): Ideally, each mode should have its own ACL. Redo 174 // this when revamping the ACL system of LUCI CV. For now, use dry run ACL 175 // for mode trigger by CQ+1 and full run ACL for mode trigger by CQ+2. 176 switch { 177 case ck.runModeDef == nil: 178 panic(fmt.Errorf("impossible; run has non standard mode %q but mode definition is not provided", ck.runMode)) 179 case ck.runModeDef.GetCqLabelValue() == 1: 180 return ck.canCreateDryRun(ctx) 181 case ck.runModeDef.GetCqLabelValue() == 2: 182 return ck.canCreateFullRun(ctx) 183 default: 184 panic(fmt.Errorf("impossible; mode specify CQ label value %d, expecting 1, or 2", ck.runModeDef.GetCqLabelValue())) 185 } 186 } 187 } 188 189 func (ck runCreateChecker) canCreateFullRun(ctx context.Context) (evalResult, error) { 190 // A committer can run a full run, as long as the CL is submittable. 191 switch isCommitter, err := ck.isCommitter(ctx, ck.triggerer); { 192 case err != nil: 193 return no, err 194 case isCommitter && (ck.submittable || ck.submitted): 195 return yes, nil 196 case isCommitter: 197 return noWithReason(notSubmittableReason(ctx, ck.cl)), nil 198 } 199 // A non-committer can trigger a full-run, 200 // if all of the following conditions are met. 201 // 202 // 1) triggerer == owner 203 // 2) triggerer is a dry-runner OR cg.AllowOwnerIfSubmittable == COMMIT 204 // 3) the CL is submittable in Gerrit. 205 // 206 // That is, a dry-runner can trigger a full-run for own submittable CLs 207 // (typically means the CL has been approved). 208 // For more context, crbug.com/692611 and go/cq-after-lgtm. 209 if ck.triggerer != ck.owner { 210 return noWithReason(fmt.Sprintf(notOwnerNotCommitter, ck.triggererEmail)), nil 211 } 212 isDryRunner, err := ck.isDryRunner(ctx, ck.triggerer) 213 if err != nil { 214 return no, err 215 } 216 if !isDryRunner && ck.allowOwnerIfSubmittable != cfgpb.Verifiers_GerritCQAbility_COMMIT { 217 return noWithReason(fmt.Sprintf(ownerNotCommitter, ck.triggererEmail)), nil 218 } 219 if !ck.submittable && !ck.submitted { 220 return noWithReason(notSubmittableReason(ctx, ck.cl)), nil 221 } 222 return yes, nil 223 } 224 225 func (ck runCreateChecker) canCreateNewPatchsetRun(ctx context.Context) (evalResult, error) { 226 switch isNPRunner, err := ck.isNewPatchsetRunner(ctx, ck.owner); { 227 case err != nil: 228 return no, err 229 case isNPRunner: 230 return yes, nil 231 default: 232 return noWithReason("CL owner is not in the allowlist."), nil 233 } 234 } 235 236 func (ck runCreateChecker) canCreateDryRun(ctx context.Context) (evalResult, error) { 237 isCommitter, err := ck.isCommitter(ctx, ck.triggerer) 238 if err != nil { 239 return no, err 240 } 241 if isCommitter { 242 switch { 243 case ck.triggerer == ck.owner: 244 // A committer can trigger a dry run on their own CL 245 // without CL being submittable. We assume dependencies 246 // are trusted since they uploaded the CL. 247 return yes, nil 248 default: 249 // In order for a committer to trigger a dry-run for someone 250 // else's CL, all the dependencies must be trusted 251 // dependencies. 252 return ck.canTrustDeps(ctx) 253 } 254 } 255 256 // A non-committer can trigger a dry-run if they are a dry-runner. 257 isDryRunner, err := ck.isDryRunner(ctx, ck.triggerer) 258 if err != nil { 259 return no, err 260 } 261 if isDryRunner { 262 switch { 263 case ck.triggerer == ck.owner: 264 // A dry-runner can trigger a dry run on their own CL without CL being 265 // submittable. We assume dependencies are trusted since they uploaded 266 // the CL. 267 return yes, nil 268 case ck.allowNonOwnerDryRunner: 269 // A dry-runner can trigger a dry run on a CL they don't own if 270 // allowNonOwnerDryRunner is set. All dependencies must be trusted. 271 return ck.canTrustDeps(ctx) 272 default: 273 // Otherwise, a dry-runner cannot trigger a dry run on 274 // a CL they don't own. 275 return noWithReason(fmt.Sprintf(notOwnerNotCommitter, ck.triggererEmail)), nil 276 } 277 } 278 279 // One can trigger a dry-run without being a dry-runner or a committer, 280 // if all the following conditions are met: 281 // 282 // 1) triggerer == owner 283 // 2) cg.AllowOwnerIfSubmittable in [COMMIT, DRY_RUN] 284 // 3) The CL is submittable in Gerrit. 285 // 4) All the deps are trusted. 286 // 287 // A dep is trusted, if at least one of the following conditions are met. 288 // - the dep is one of the CLs included in the Run 289 // - the owner of the dep is a committer 290 // - the dep is submittable in Gerrit 291 // 292 // For more context, crbug.com/692611 and go/cq-after-lgtm. 293 if ck.triggerer != ck.owner { 294 reason := notOwnerNotCommitter 295 if ck.allowNonOwnerDryRunner { 296 reason = notOwnerNotCommitterNotDryRunner 297 } 298 return noWithReason(fmt.Sprintf(reason, ck.triggererEmail)), nil 299 } 300 301 switch ck.allowOwnerIfSubmittable { 302 case cfgpb.Verifiers_GerritCQAbility_DRY_RUN: 303 case cfgpb.Verifiers_GerritCQAbility_COMMIT: 304 default: 305 return noWithReason(fmt.Sprintf(ownerNotDryRunner, ck.triggererEmail)), nil 306 } 307 if !ck.submittable && !ck.submitted { 308 return noWithReason(notSubmittableReason(ctx, ck.cl)), nil 309 } 310 return ck.canTrustDeps(ctx) 311 } 312 313 func (ck runCreateChecker) isDryRunner(ctx context.Context, id identity.Identity) (bool, error) { 314 if len(ck.dryGroups) == 0 { 315 return false, nil 316 } 317 return auth.GetState(ctx).DB().IsMember(ctx, id, ck.dryGroups) 318 } 319 320 func (ck runCreateChecker) isNewPatchsetRunner(ctx context.Context, id identity.Identity) (bool, error) { 321 if len(ck.newPatchsetGroups) == 0 { 322 return false, nil 323 } 324 return auth.GetState(ctx).DB().IsMember(ctx, id, ck.newPatchsetGroups) 325 } 326 327 func (ck runCreateChecker) isCommitter(ctx context.Context, id identity.Identity) (bool, error) { 328 if len(ck.commGroups) == 0 { 329 return false, nil 330 } 331 return auth.GetState(ctx).DB().IsMember(ctx, id, ck.commGroups) 332 } 333 334 // CheckRunCreate verifies that the user(s) who triggered Run are authorized 335 // to create the Run for the CLs. 336 func CheckRunCreate(ctx context.Context, cg *prjcfg.ConfigGroup, trs []*run.Trigger, cls []*changelist.CL) (CheckResult, error) { 337 res := make(CheckResult, len(cls)) 338 cks, err := evaluateCLs(ctx, cg, trs, cls) 339 if err != nil { 340 return nil, err 341 } 342 for _, ck := range cks { 343 switch result, err := ck.canCreateRun(ctx); { 344 case err != nil: 345 return nil, err 346 case !result.ok: 347 res[ck.cl] = result.reason 348 } 349 } 350 return res, nil 351 } 352 353 func evaluateCLs(ctx context.Context, cg *prjcfg.ConfigGroup, trs []*run.Trigger, cls []*changelist.CL) ([]*runCreateChecker, error) { 354 gVerifier := cg.Content.Verifiers.GetGerritCqAbility() 355 356 cks := make([]*runCreateChecker, len(cls)) 357 trustedDeps := make(common.CLIDsSet, len(cls)) 358 for i, cl := range cls { 359 tr := trs[i] 360 triggerer, err := identity.MakeIdentity(fmt.Sprintf("%s:%s", identity.User, tr.Email)) 361 if err != nil { 362 return nil, errors.Annotate(err, "CL(%d): triggerer %q", cl.ID, tr.Email).Err() 363 } 364 owner, err := cl.Snapshot.OwnerIdentity() 365 if err != nil { 366 return nil, errors.Annotate(err, "CL(%d)", cl.ID).Err() 367 } 368 369 submittable, err := cl.Snapshot.IsSubmittable() 370 if err != nil { 371 return nil, errors.Annotate(err, "CL(%d)", cl.ID).Err() 372 } 373 submitted, err := cl.Snapshot.IsSubmitted() 374 if err != nil { 375 return nil, errors.Annotate(err, "CL(%d)", cl.ID).Err() 376 } 377 // by default, all deps are untrusted, unless they are part of the Run. 378 var depsToExamine common.CLIDs 379 if len(cl.Snapshot.Deps) > 0 { 380 depsToExamine = make(common.CLIDs, len(cl.Snapshot.Deps)) 381 for i, d := range cl.Snapshot.Deps { 382 depsToExamine[i] = common.CLID(d.Clid) 383 } 384 } 385 trustedDeps.Add(cl.ID) 386 cks[i] = &runCreateChecker{ 387 cl: cl, 388 runMode: run.Mode(tr.Mode), 389 runModeDef: tr.GetModeDefinition(), 390 allowOwnerIfSubmittable: gVerifier.GetAllowOwnerIfSubmittable(), 391 trustDryRunnerDeps: gVerifier.GetTrustDryRunnerDeps(), 392 allowNonOwnerDryRunner: gVerifier.GetAllowNonOwnerDryRunner(), 393 commGroups: gVerifier.GetCommitterList(), 394 dryGroups: gVerifier.GetDryRunAccessList(), 395 newPatchsetGroups: gVerifier.GetNewPatchsetRunAccessList(), 396 397 owner: owner, 398 triggerer: triggerer, 399 triggererEmail: tr.Email, 400 submittable: submittable, 401 submitted: submitted, 402 depsToExamine: depsToExamine, 403 trustedDeps: trustedDeps, 404 } 405 } 406 return cks, nil 407 } 408 409 // untrustedDepsReason generates a RunCreate rejection comment for untrusted deps. 410 func untrustedDepsReason(ctx context.Context, udeps []*changelist.CL, trustDryRunnerDeps bool) string { 411 var sb strings.Builder 412 anySuspicious := false 413 if trustDryRunnerDeps { 414 sb.WriteString(untrustedDepsTrustDryRunnerDeps) 415 } else { 416 sb.WriteString(untrustedDeps) 417 } 418 for _, d := range udeps { 419 fmt.Fprintf(&sb, "\n- %s:", d.ExternalID.MustURL()) 420 if allSatisfied, msg := strSubmitReqsForNotSubmittableCL(ctx, d); len(msg) > 0 { 421 fmt.Fprintf(&sb, " %s", msg) 422 anySuspicious = anySuspicious || allSatisfied 423 } 424 } 425 if anySuspicious { 426 fmt.Fprintf(&sb, "\n\n%s", untrustedDepsSuspicious) 427 } 428 return sb.String() 429 } 430 431 // notSubmittableReason generates a RunCreate rejection comment for not 432 // submittable CL. 433 func notSubmittableReason(ctx context.Context, cl *changelist.CL) string { 434 switch allSatisfied, msg := strSubmitReqsForNotSubmittableCL(ctx, cl); { 435 case allSatisfied: 436 return notSubmittableSuspicious 437 case len(msg) > 0: 438 return fmt.Sprintf(notSubmittableWithReqs, msg) 439 } 440 return notSubmittable 441 } 442 443 func strSubmitReqsForNotSubmittableCL(ctx context.Context, cl *changelist.CL) (allSatisfied bool, msg string) { 444 reqs := cl.Snapshot.GetGerrit().GetInfo().GetSubmitRequirements() 445 if len(reqs) == 0 { 446 return 447 } 448 join := func(ss []string) string { 449 switch len(ss) { 450 case 0: 451 return "" 452 case 1: 453 return fmt.Sprintf("`%s`", ss[0]) 454 case 2: 455 return fmt.Sprintf("`%s` and `%s`", ss[0], ss[1]) 456 default: 457 last := len(ss) - 1 458 return fmt.Sprintf("`%s`, and `%s`", strings.Join(ss[:last], "`, `"), ss[last]) 459 } 460 } 461 462 switch satisfied, unsatisfied := groupSubmitReqs(ctx, reqs); { 463 case len(unsatisfied) == 0: 464 switch len(satisfied) { 465 case 0: 466 // all were NOT_APPLICABLE? 467 // just log the occurrence, but consider that 468 // submit requirements agreed with Submittable. 469 logging.Errorf(ctx, "CL(%d): all submit reqs(%d) are NOT_APPLICABLE", cl.ID, len(reqs)) 470 case 1: 471 msg = fmt.Sprintf("not submittable, although submit requirement `%s` is satisfied", satisfied[0]) 472 default: 473 msg = fmt.Sprintf("not submittable, although submit requirements %s are satisfied", join(satisfied)) 474 } 475 allSatisfied = len(satisfied) != 0 476 default: 477 msg = fmt.Sprintf("not satisfying the %s submit requirement", join(unsatisfied)) 478 } 479 if allSatisfied { 480 logging.Errorf(ctx, "CL(%d): all submit reqs satisfied; but CL not submittable", cl.ID) 481 } 482 return 483 } 484 485 func groupSubmitReqs(ctx context.Context, reqs []*gerritpb.SubmitRequirementResultInfo) (satisfied, unsatisfied []string) { 486 if len(reqs) == 0 { 487 return 488 } 489 satisfied = make([]string, 0, len(reqs)) 490 unsatisfied = make([]string, 0, len(reqs)) 491 for _, req := range reqs { 492 switch req.Status { 493 case gerritpb.SubmitRequirementResultInfo_SUBMIT_REQUIREMENT_STATUS_UNSPECIFIED: 494 panic(errors.New("Unspecified SubmitRequirement.Status; this should never happen")) 495 case gerritpb.SubmitRequirementResultInfo_NOT_APPLICABLE: 496 497 // satisfied statuses 498 case gerritpb.SubmitRequirementResultInfo_SATISFIED, 499 gerritpb.SubmitRequirementResultInfo_OVERRIDDEN, 500 gerritpb.SubmitRequirementResultInfo_FORCED: 501 satisfied = append(satisfied, req.Name) 502 503 // unsatisfied statuses 504 case gerritpb.SubmitRequirementResultInfo_ERROR: 505 // log the error. It may be helpful for diagnosing the reason of a Run rejection. 506 logging.Warningf(ctx, "Gerrit reported SubmissionRequirement error %s", req) 507 fallthrough 508 case gerritpb.SubmitRequirementResultInfo_UNSATISFIED: 509 unsatisfied = append(unsatisfied, req.Name) 510 511 default: 512 // This must be a bug in CV. 513 // 514 // common/api/gerrit returns an error if it receives a Status of which enum 515 // doesn't exist in common/proto/gerrit. Hence, if a Status is unknown here, 516 // this switch is missing the status, enumerated in common/proto/gerrit. 517 logging.Errorf(ctx, "Unknown SubmitRequirementStatus %q", req.GetStatus()) 518 // Unknown enums are considered as a not-satisfied status. 519 unsatisfied = append(unsatisfied, req.Name) 520 } 521 } 522 return 523 }