| // Copyright 2022 The LUCI Authors. |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| package acls |
| |
| import ( |
| "context" |
| "fmt" |
| "strings" |
| |
| "go.chromium.org/luci/auth/identity" |
| "go.chromium.org/luci/common/errors" |
| "go.chromium.org/luci/common/logging" |
| gerritpb "go.chromium.org/luci/common/proto/gerrit" |
| |
| "go.chromium.org/luci/cv/internal/changelist" |
| "go.chromium.org/luci/cv/internal/common" |
| "go.chromium.org/luci/cv/internal/configs/prjcfg" |
| "go.chromium.org/luci/cv/internal/gerrit" |
| "go.chromium.org/luci/cv/internal/run" |
| |
| cfgpb "go.chromium.org/luci/cv/api/config/v2" |
| ) |
| |
| const ( |
| okButDueToOthers = "CV cannot start a Run due to errors in the following CL(s)." |
| ownerNotCommitter = "CV cannot start a Run for `%s` because the user is not a committer." |
| ownerNotDryRunner = "CV cannot start a Run for `%s` because the user is not a dry-runner." |
| notOwnerNotCommitter = "CV cannot start a Run for `%s` because the user is neither the CL owner nor a committer." |
| notOwnerNotCommitterNotDryRunner = "CV cannot start a Run for `%s` because the user is neither the CL owner nor a committer nor a dry-runner." |
| |
| notSubmittable = "CV cannot start a Run because this CL is not submittable. " + submitReqHint |
| notSubmittableWithReqs = "CV cannot start a Run because this CL is %s. " + submitReqHint |
| notSubmittableSuspicious = notSubmittable + " " + |
| "However, all submit requirements appear to be satisfied. " + |
| "It's likely caused by an issue in Gerrit or Gerrit configuration. " + |
| "Please contact your Git admin." |
| submitReqHint = "Please hover over the corresponding entry in the Submit Requirements section to check what is missing." |
| |
| untrustedDeps = "" + |
| "CV cannot start a Run because of the following dependencies. " + |
| "They must be submittable (please check the submit requirement) because " + |
| "their owners are not committers. " + |
| "Alternatively, you can ask the owner of this CL to trigger a dry-run." |
| untrustedDepsTrustDryRunnerDeps = "" + |
| "CV cannot start a Run because of the following dependencies. " + |
| "They must be submittable (please check the submit requirement) because " + |
| "their owners are not committers or dry-runners. " + |
| "Alternatively, you can ask the owner of this CL to trigger a dry-run." |
| untrustedDepsSuspicious = "" + |
| "However, some or all of the dependencies appear to satisfy all the requirements. " + |
| "It's likely caused by an issue in Gerrit or Gerrit configuration. " + |
| "Please contact your Git admin." |
| ) |
| |
| // runCreateChecker holds the evaluation results of a CL Run, and checks |
| // if the Run can be created. |
| type runCreateChecker struct { |
| gf gerrit.Factory |
| |
| cl *changelist.CL |
| runMode run.Mode |
| runModeDef *cfgpb.Mode // if mode is not standard mode in CV |
| allowOwnerIfSubmittable cfgpb.Verifiers_GerritCQAbility_CQAction |
| trustDryRunnerDeps bool |
| allowNonOwnerDryRunner bool |
| commGroups []string // committer groups |
| dryGroups []string // dry-runner groups |
| newPatchsetGroups []string // new patchset run groups |
| |
| owner identity.Identity // the CL owner |
| triggerer identity.Identity // the Run triggerer |
| triggererEmail string // email of the Run triggerer |
| submittable bool // if the CL is submittable in Gerrit |
| submitted bool // if the CL has been submitted in Gerrit |
| depsToExamine common.CLIDs // deps that are possibly untrusted. |
| trustedDeps common.CLIDsSet // deps that have been proven to be trustable. |
| } |
| |
| func (ck runCreateChecker) canTrustDeps(ctx context.Context) (evalResult, error) { |
| if len(ck.depsToExamine) == 0 { |
| return yes, nil |
| } |
| deps := make([]*changelist.CL, 0, len(ck.depsToExamine)) |
| for _, id := range ck.depsToExamine { |
| if !ck.trustedDeps.Has(id) { |
| deps = append(deps, &changelist.CL{ID: id}) |
| } |
| } |
| if len(deps) == 0 { |
| return yes, nil |
| } |
| |
| // Fetch the CL entity of the deps and examine if they are trustable. |
| // CV never removes CL entities. Hence, this handles transient and |
| // datastore.ErrNoSuchEntity in the same way. |
| if err := changelist.LoadCLs(ctx, deps); err != nil { |
| return no, err |
| } |
| untrusted := deps[:0] |
| for _, d := range deps { |
| // Dep is trusted, if |
| // - it has been submitted, OR |
| // - it is submittable, OR |
| // - the owner is a committer, OR |
| // - config enables trust_dry_runner_deps and the owner is a dry runner |
| switch submitted, err := d.Snapshot.IsSubmitted(); { |
| case err != nil: |
| return no, errors.Annotate(err, "dep-CL(%d)", d.ID).Err() |
| case submitted: |
| ck.trustedDeps.Add(d.ID) |
| continue |
| } |
| switch submittable, err := d.Snapshot.IsSubmittable(); { |
| case err != nil: |
| return no, errors.Annotate(err, "dep-CL(%d)", d.ID).Err() |
| case submittable: |
| ck.trustedDeps.Add(d.ID) |
| continue |
| } |
| |
| depOwner, err := d.Snapshot.OwnerIdentity() |
| if err != nil { |
| return no, errors.Annotate(err, "dep-CL(%d)", d.ID).Err() |
| } |
| |
| switch isCommitter, err := ck.isCommitter(ctx, depOwner); { |
| case err != nil: |
| return no, errors.Annotate(err, |
| "dep-CL(%d): checking if owner %q is a committer", d.ID, depOwner).Err() |
| case isCommitter: |
| ck.trustedDeps.Add(d.ID) |
| continue |
| } |
| |
| if ck.trustDryRunnerDeps { |
| switch isDryRunner, err := ck.isDryRunner(ctx, depOwner); { |
| case err != nil: |
| return no, errors.Annotate(err, |
| "dep-CL(%d): checking if owner %q is a dry-runner", d.ID, depOwner).Err() |
| case isDryRunner: |
| ck.trustedDeps.Add(d.ID) |
| continue |
| } |
| } |
| |
| untrusted = append(untrusted, d) |
| } |
| if len(untrusted) == 0 { |
| return yes, nil |
| } |
| return noWithReason(untrustedDepsReason(ctx, untrusted, ck.trustDryRunnerDeps)), nil |
| } |
| |
| func (ck runCreateChecker) canCreateRun(ctx context.Context) (evalResult, error) { |
| switch ck.runMode { |
| case run.FullRun: |
| return ck.canCreateFullRun(ctx) |
| case run.DryRun: |
| return ck.canCreateDryRun(ctx) |
| case run.NewPatchsetRun: |
| return ck.canCreateNewPatchsetRun(ctx) |
| default: |
| // TODO(yiwzhang): Ideally, each mode should have its own ACL. Redo |
| // this when revamping the ACL system of LUCI CV. For now, use dry run ACL |
| // for mode trigger by CQ+1 and full run ACL for mode trigger by CQ+2. |
| switch { |
| case ck.runModeDef == nil: |
| panic(fmt.Errorf("impossible; run has non standard mode %q but mode definition is not provided", ck.runMode)) |
| case ck.runModeDef.GetCqLabelValue() == 1: |
| return ck.canCreateDryRun(ctx) |
| case ck.runModeDef.GetCqLabelValue() == 2: |
| return ck.canCreateFullRun(ctx) |
| default: |
| panic(fmt.Errorf("impossible; mode specify CQ label value %d, expecting 1, or 2", ck.runModeDef.GetCqLabelValue())) |
| } |
| } |
| } |
| |
| func (ck runCreateChecker) canCreateFullRun(ctx context.Context) (evalResult, error) { |
| // A committer can run a full run, as long as the CL is submittable. |
| switch isCommitter, err := ck.isCommitter(ctx, ck.triggerer); { |
| case err != nil: |
| return no, err |
| case isCommitter && (ck.submittable || ck.submitted): |
| return yes, nil |
| case isCommitter: |
| return noWithReason(notSubmittableReason(ctx, ck.cl)), nil |
| } |
| // A non-committer can trigger a full-run, |
| // if all of the following conditions are met. |
| // |
| // 1) triggerer == owner |
| // 2) triggerer is a dry-runner OR cg.AllowOwnerIfSubmittable == COMMIT |
| // 3) the CL is submittable in Gerrit. |
| // |
| // That is, a dry-runner can trigger a full-run for own submittable CLs |
| // (typically means the CL has been approved). |
| // For more context, crbug.com/692611 and go/cq-after-lgtm. |
| if ck.triggerer != ck.owner { |
| return noWithReason(fmt.Sprintf(notOwnerNotCommitter, ck.triggererEmail)), nil |
| } |
| isDryRunner, err := ck.isDryRunner(ctx, ck.triggerer) |
| if err != nil { |
| return no, err |
| } |
| if !isDryRunner && ck.allowOwnerIfSubmittable != cfgpb.Verifiers_GerritCQAbility_COMMIT { |
| return noWithReason(fmt.Sprintf(ownerNotCommitter, ck.triggererEmail)), nil |
| } |
| if !ck.submittable && !ck.submitted { |
| return noWithReason(notSubmittableReason(ctx, ck.cl)), nil |
| } |
| return yes, nil |
| } |
| |
| func (ck runCreateChecker) canCreateNewPatchsetRun(ctx context.Context) (evalResult, error) { |
| switch isNPRunner, err := ck.isNewPatchsetRunner(ctx, ck.owner); { |
| case err != nil: |
| return no, err |
| case isNPRunner: |
| return yes, nil |
| default: |
| return noWithReason("CL owner is not in the allowlist."), nil |
| } |
| } |
| |
| func (ck runCreateChecker) canCreateDryRun(ctx context.Context) (evalResult, error) { |
| isCommitter, err := ck.isCommitter(ctx, ck.triggerer) |
| if err != nil { |
| return no, err |
| } |
| if isCommitter { |
| switch { |
| case ck.triggerer == ck.owner: |
| // A committer can trigger a dry run on their own CL |
| // without CL being submittable. We assume dependencies |
| // are trusted since they uploaded the CL. |
| return yes, nil |
| default: |
| // In order for a committer to trigger a dry-run for someone |
| // else's CL, all the dependencies must be trusted |
| // dependencies. |
| return ck.canTrustDeps(ctx) |
| } |
| } |
| |
| // A non-committer can trigger a dry-run if they are a dry-runner. |
| isDryRunner, err := ck.isDryRunner(ctx, ck.triggerer) |
| if err != nil { |
| return no, err |
| } |
| if isDryRunner { |
| switch { |
| case ck.triggerer == ck.owner: |
| // A dry-runner can trigger a dry run on their own CL without CL being |
| // submittable. We assume dependencies are trusted since they uploaded |
| // the CL. |
| return yes, nil |
| case ck.allowNonOwnerDryRunner: |
| // A dry-runner can trigger a dry run on a CL they don't own if |
| // allowNonOwnerDryRunner is set. All dependencies must be trusted. |
| return ck.canTrustDeps(ctx) |
| default: |
| // Otherwise, a dry-runner cannot trigger a dry run on |
| // a CL they don't own. |
| return noWithReason(fmt.Sprintf(notOwnerNotCommitter, ck.triggererEmail)), nil |
| } |
| } |
| |
| // One can trigger a dry-run without being a dry-runner or a committer, |
| // if all the following conditions are met: |
| // |
| // 1) triggerer == owner |
| // 2) cg.AllowOwnerIfSubmittable in [COMMIT, DRY_RUN] |
| // 3) The CL is submittable in Gerrit. |
| // 4) All the deps are trusted. |
| // |
| // A dep is trusted, if at least one of the following conditions are met. |
| // - the dep is one of the CLs included in the Run |
| // - the owner of the dep is a committer |
| // - the dep is submittable in Gerrit |
| // |
| // For more context, crbug.com/692611 and go/cq-after-lgtm. |
| if ck.triggerer != ck.owner { |
| reason := notOwnerNotCommitter |
| if ck.allowNonOwnerDryRunner { |
| reason = notOwnerNotCommitterNotDryRunner |
| } |
| return noWithReason(fmt.Sprintf(reason, ck.triggererEmail)), nil |
| } |
| |
| switch ck.allowOwnerIfSubmittable { |
| case cfgpb.Verifiers_GerritCQAbility_DRY_RUN: |
| case cfgpb.Verifiers_GerritCQAbility_COMMIT: |
| default: |
| return noWithReason(fmt.Sprintf(ownerNotDryRunner, ck.triggererEmail)), nil |
| } |
| if !ck.submittable && !ck.submitted { |
| return noWithReason(notSubmittableReason(ctx, ck.cl)), nil |
| } |
| return ck.canTrustDeps(ctx) |
| } |
| |
| func (ck runCreateChecker) isDryRunner(ctx context.Context, id identity.Identity) (bool, error) { |
| if len(ck.dryGroups) == 0 { |
| return false, nil |
| } |
| return IsMember(ctx, ck.gf, ck.cl.Snapshot.GetGerrit().GetHost(), ck.cl.Snapshot.GetLuciProject(), id, ck.dryGroups) |
| } |
| |
| func (ck runCreateChecker) isNewPatchsetRunner(ctx context.Context, id identity.Identity) (bool, error) { |
| if len(ck.newPatchsetGroups) == 0 { |
| return false, nil |
| } |
| return IsMember(ctx, ck.gf, ck.cl.Snapshot.GetGerrit().GetHost(), ck.cl.Snapshot.GetLuciProject(), id, ck.newPatchsetGroups) |
| } |
| |
| func (ck runCreateChecker) isCommitter(ctx context.Context, id identity.Identity) (bool, error) { |
| if len(ck.commGroups) == 0 { |
| return false, nil |
| } |
| return IsMember(ctx, ck.gf, ck.cl.Snapshot.GetGerrit().GetHost(), ck.cl.Snapshot.GetLuciProject(), id, ck.commGroups) |
| } |
| |
| // CheckRunCreate verifies that the user(s) who triggered Run are authorized |
| // to create the Run for the CLs. |
| func CheckRunCreate(ctx context.Context, gf gerrit.Factory, cg *prjcfg.ConfigGroup, trs []*run.Trigger, cls []*changelist.CL) (CheckResult, error) { |
| res := make(CheckResult, len(cls)) |
| cks, err := evaluateCLs(ctx, gf, cg, trs, cls) |
| if err != nil { |
| return nil, err |
| } |
| for _, ck := range cks { |
| switch result, err := ck.canCreateRun(ctx); { |
| case err != nil: |
| return nil, err |
| case !result.ok: |
| res[ck.cl] = result.reason |
| } |
| } |
| return res, nil |
| } |
| |
| func evaluateCLs(ctx context.Context, gf gerrit.Factory, cg *prjcfg.ConfigGroup, trs []*run.Trigger, cls []*changelist.CL) ([]*runCreateChecker, error) { |
| gVerifier := cg.Content.Verifiers.GetGerritCqAbility() |
| |
| cks := make([]*runCreateChecker, len(cls)) |
| trustedDeps := make(common.CLIDsSet, len(cls)) |
| for i, cl := range cls { |
| tr := trs[i] |
| triggerer, err := identity.MakeIdentity(fmt.Sprintf("%s:%s", identity.User, tr.Email)) |
| if err != nil { |
| return nil, errors.Annotate(err, "CL(%d): triggerer %q", cl.ID, tr.Email).Err() |
| } |
| owner, err := cl.Snapshot.OwnerIdentity() |
| if err != nil { |
| return nil, errors.Annotate(err, "CL(%d)", cl.ID).Err() |
| } |
| |
| submittable, err := cl.Snapshot.IsSubmittable() |
| if err != nil { |
| return nil, errors.Annotate(err, "CL(%d)", cl.ID).Err() |
| } |
| submitted, err := cl.Snapshot.IsSubmitted() |
| if err != nil { |
| return nil, errors.Annotate(err, "CL(%d)", cl.ID).Err() |
| } |
| // by default, all deps are untrusted, unless they are part of the Run. |
| var depsToExamine common.CLIDs |
| if len(cl.Snapshot.Deps) > 0 { |
| depsToExamine = make(common.CLIDs, len(cl.Snapshot.Deps)) |
| for i, d := range cl.Snapshot.Deps { |
| depsToExamine[i] = common.CLID(d.Clid) |
| } |
| } |
| trustedDeps.Add(cl.ID) |
| cks[i] = &runCreateChecker{ |
| gf: gf, |
| cl: cl, |
| runMode: run.Mode(tr.Mode), |
| runModeDef: tr.GetModeDefinition(), |
| allowOwnerIfSubmittable: gVerifier.GetAllowOwnerIfSubmittable(), |
| trustDryRunnerDeps: gVerifier.GetTrustDryRunnerDeps(), |
| allowNonOwnerDryRunner: gVerifier.GetAllowNonOwnerDryRunner(), |
| commGroups: gVerifier.GetCommitterList(), |
| dryGroups: gVerifier.GetDryRunAccessList(), |
| newPatchsetGroups: gVerifier.GetNewPatchsetRunAccessList(), |
| |
| owner: owner, |
| triggerer: triggerer, |
| triggererEmail: tr.Email, |
| submittable: submittable, |
| submitted: submitted, |
| depsToExamine: depsToExamine, |
| trustedDeps: trustedDeps, |
| } |
| } |
| return cks, nil |
| } |
| |
| // untrustedDepsReason generates a RunCreate rejection comment for untrusted deps. |
| func untrustedDepsReason(ctx context.Context, udeps []*changelist.CL, trustDryRunnerDeps bool) string { |
| var sb strings.Builder |
| anySuspicious := false |
| if trustDryRunnerDeps { |
| sb.WriteString(untrustedDepsTrustDryRunnerDeps) |
| } else { |
| sb.WriteString(untrustedDeps) |
| } |
| for _, d := range udeps { |
| fmt.Fprintf(&sb, "\n- %s:", d.ExternalID.MustURL()) |
| if allSatisfied, msg := strSubmitReqsForNotSubmittableCL(ctx, d); len(msg) > 0 { |
| fmt.Fprintf(&sb, " %s", msg) |
| anySuspicious = anySuspicious || allSatisfied |
| } |
| } |
| if anySuspicious { |
| fmt.Fprintf(&sb, "\n\n%s", untrustedDepsSuspicious) |
| } |
| return sb.String() |
| } |
| |
| // notSubmittableReason generates a RunCreate rejection comment for not |
| // submittable CL. |
| func notSubmittableReason(ctx context.Context, cl *changelist.CL) string { |
| switch allSatisfied, msg := strSubmitReqsForNotSubmittableCL(ctx, cl); { |
| case allSatisfied: |
| return notSubmittableSuspicious |
| case len(msg) > 0: |
| return fmt.Sprintf(notSubmittableWithReqs, msg) |
| } |
| return notSubmittable |
| } |
| |
| func strSubmitReqsForNotSubmittableCL(ctx context.Context, cl *changelist.CL) (allSatisfied bool, msg string) { |
| reqs := cl.Snapshot.GetGerrit().GetInfo().GetSubmitRequirements() |
| if len(reqs) == 0 { |
| return |
| } |
| join := func(ss []string) string { |
| switch len(ss) { |
| case 0: |
| return "" |
| case 1: |
| return fmt.Sprintf("`%s`", ss[0]) |
| case 2: |
| return fmt.Sprintf("`%s` and `%s`", ss[0], ss[1]) |
| default: |
| last := len(ss) - 1 |
| return fmt.Sprintf("`%s`, and `%s`", strings.Join(ss[:last], "`, `"), ss[last]) |
| } |
| } |
| |
| switch satisfied, unsatisfied := groupSubmitReqs(ctx, reqs); { |
| case len(unsatisfied) == 0: |
| switch len(satisfied) { |
| case 0: |
| // all were NOT_APPLICABLE? |
| // just log the occurrence, but consider that |
| // submit requirements agreed with Submittable. |
| logging.Errorf(ctx, "CL(%d): all submit reqs(%d) are NOT_APPLICABLE", cl.ID, len(reqs)) |
| case 1: |
| msg = fmt.Sprintf("not submittable, although submit requirement `%s` is satisfied", satisfied[0]) |
| default: |
| msg = fmt.Sprintf("not submittable, although submit requirements %s are satisfied", join(satisfied)) |
| } |
| allSatisfied = len(satisfied) != 0 |
| default: |
| msg = fmt.Sprintf("not satisfying the %s submit requirement", join(unsatisfied)) |
| } |
| if allSatisfied { |
| logging.Errorf(ctx, "CL(%d): all submit reqs satisfied; but CL not submittable", cl.ID) |
| } |
| return |
| } |
| |
| func groupSubmitReqs(ctx context.Context, reqs []*gerritpb.SubmitRequirementResultInfo) (satisfied, unsatisfied []string) { |
| if len(reqs) == 0 { |
| return |
| } |
| satisfied = make([]string, 0, len(reqs)) |
| unsatisfied = make([]string, 0, len(reqs)) |
| for _, req := range reqs { |
| switch req.Status { |
| case gerritpb.SubmitRequirementResultInfo_SUBMIT_REQUIREMENT_STATUS_UNSPECIFIED: |
| panic(errors.New("Unspecified SubmitRequirement.Status; this should never happen")) |
| case gerritpb.SubmitRequirementResultInfo_NOT_APPLICABLE: |
| |
| // satisfied statuses |
| case gerritpb.SubmitRequirementResultInfo_SATISFIED, |
| gerritpb.SubmitRequirementResultInfo_OVERRIDDEN, |
| gerritpb.SubmitRequirementResultInfo_FORCED: |
| satisfied = append(satisfied, req.Name) |
| |
| // unsatisfied statuses |
| case gerritpb.SubmitRequirementResultInfo_ERROR: |
| // log the error. It may be helpful for diagnosing the reason of a Run rejection. |
| logging.Warningf(ctx, "Gerrit reported SubmissionRequirement error %s", req) |
| fallthrough |
| case gerritpb.SubmitRequirementResultInfo_UNSATISFIED: |
| unsatisfied = append(unsatisfied, req.Name) |
| |
| default: |
| // This must be a bug in CV. |
| // |
| // common/api/gerrit returns an error if it receives a Status of which enum |
| // doesn't exist in common/proto/gerrit. Hence, if a Status is unknown here, |
| // this switch is missing the status, enumerated in common/proto/gerrit. |
| logging.Errorf(ctx, "Unknown SubmitRequirementStatus %q", req.GetStatus()) |
| // Unknown enums are considered as a not-satisfied status. |
| unsatisfied = append(unsatisfied, req.Name) |
| } |
| } |
| return |
| } |