go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/buildbucket/appengine/model/mask.go (about)

     1  // Copyright 2021 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 model
    16  
    17  import (
    18  	"fmt"
    19  	"strings"
    20  
    21  	"google.golang.org/protobuf/proto"
    22  	"google.golang.org/protobuf/reflect/protoreflect"
    23  	"google.golang.org/protobuf/types/descriptorpb"
    24  	"google.golang.org/protobuf/types/known/fieldmaskpb"
    25  
    26  	pb "go.chromium.org/luci/buildbucket/proto"
    27  	"go.chromium.org/luci/common/errors"
    28  	"go.chromium.org/luci/common/proto/mask"
    29  	"go.chromium.org/luci/common/proto/structmask"
    30  )
    31  
    32  // The default field mask to use for read requests.
    33  var defaultFieldMask = fieldmaskpb.FieldMask{
    34  	Paths: []string{
    35  		"builder",
    36  		"canary",
    37  		"create_time",
    38  		"created_by",
    39  		"critical",
    40  		"end_time",
    41  		"id",
    42  		"input.experimental",
    43  		"input.gerrit_changes",
    44  		"input.gitiles_commit",
    45  		"number",
    46  		"start_time",
    47  		"status",
    48  		"status_details",
    49  		"update_time",
    50  	},
    51  }
    52  
    53  // Used just for their type information.
    54  var (
    55  	buildPrototype       = pb.Build{}
    56  	searchBuildPrototype = pb.SearchBuildsResponse{}
    57  )
    58  
    59  // NoopBuildMask selects all fields.
    60  var NoopBuildMask = &BuildMask{m: mask.All(&buildPrototype)}
    61  
    62  // DefaultBuildMask is the default mask to use for read requests.
    63  var DefaultBuildMask = HardcodedBuildMask(defaultFieldMask.Paths...)
    64  
    65  // ListOnlyBuildMask is an extra mask to hide fields from callers who have the BuildsList
    66  // permission but not BuildsGet or BuildsGetLimited.
    67  // These callers should only be able to see fields specified in this mask.
    68  var ListOnlyBuildMask = HardcodedBuildMask(BuildFieldsWithVisibility(pb.BuildFieldVisibility_BUILDS_LIST_PERMISSION)...)
    69  
    70  // GetLimitedBuildMask is an extra mask to hide fields from callers who have the BuildsGetLimited
    71  // permission but not BuildsGet.
    72  // These callers should only be able to see fields specified in this mask.
    73  var GetLimitedBuildMask = HardcodedBuildMask(BuildFieldsWithVisibility(pb.BuildFieldVisibility_BUILDS_GET_LIMITED_PERMISSION)...)
    74  
    75  // BuildMask knows how to filter pb.Build proto messages.
    76  type BuildMask struct {
    77  	m            *mask.Mask             // the overall field mask
    78  	in           *structmask.Filter     // "input.properties" filter
    79  	out          *structmask.Filter     // "output.properties" filter
    80  	req          *structmask.Filter     // "infra.buildbucket.requested_properties" filter
    81  	stepStatuses map[pb.Status]struct{} // "steps.status" filter
    82  	allFields    bool                   // Flag for including all fields.
    83  }
    84  
    85  // NewBuildMask constructs a build mask either using a legacy `fields` FieldMask
    86  // or new `mask` BuildMask (but not both at the same time, pick one).
    87  //
    88  // legacyPrefix is usually "", but can be "builds" to trim "builds." from
    89  // the legacy field mask (used by SearchBuilds API).
    90  //
    91  // If the mask is empty, returns DefaultBuildMask.
    92  func NewBuildMask(legacyPrefix string, legacy *fieldmaskpb.FieldMask, bm *pb.BuildMask) (*BuildMask, error) {
    93  	switch {
    94  	case legacy == nil && bm == nil:
    95  		return DefaultBuildMask, nil
    96  	case legacy != nil && bm != nil:
    97  		return nil, errors.Reason("`mask` and `fields` can't be used together, prefer `mask` since `fields` is deprecated").Err()
    98  	case legacy != nil:
    99  		return newLegacyBuildMask(legacyPrefix, legacy)
   100  	}
   101  
   102  	// Filter unique statuses.
   103  	stepStatuses := make(map[pb.Status]struct{}, len(pb.Status_name))
   104  	for _, st := range bm.StepStatus {
   105  		stepStatuses[st] = struct{}{}
   106  	}
   107  
   108  	if bm.GetAllFields() {
   109  		// All fields should be included.
   110  		if len(bm.GetFields().GetPaths()) > 0 || len(bm.GetInputProperties()) > 0 || len(bm.GetOutputProperties()) > 0 || len(bm.GetRequestedProperties()) > 0 {
   111  			return nil, errors.New("mask.AllFields is mutually exclusive with other mask fields")
   112  		}
   113  		return &BuildMask{allFields: true, stepStatuses: stepStatuses}, nil
   114  	}
   115  
   116  	fm := bm.Fields
   117  	if len(fm.GetPaths()) == 0 {
   118  		fm = &defaultFieldMask
   119  	}
   120  
   121  	var cloned bool
   122  	structFilter := func(path string, structMask []*structmask.StructMask) (*structmask.Filter, error) {
   123  		if len(structMask) == 0 {
   124  			return nil, nil
   125  		}
   126  		// Implicitly include struct-valued fields when their masks are present.
   127  		// Make sure not to accidentally override the original FieldMask
   128  		// (in particular when it is &defaultFieldMask).
   129  		if !cloned {
   130  			fm = proto.Clone(fm).(*fieldmaskpb.FieldMask)
   131  			cloned = true
   132  		}
   133  		fm.Paths = append(fm.Paths, path)
   134  		return structmask.NewFilter(structMask)
   135  	}
   136  
   137  	// Parse struct masks. This also mutates `fm` to include corresponding fields.
   138  	in, err := structFilter("input.properties", bm.InputProperties)
   139  	if err != nil {
   140  		return nil, errors.Annotate(err, `bad "input_properties" struct mask`).Err()
   141  	}
   142  	out, err := structFilter("output.properties", bm.OutputProperties)
   143  	if err != nil {
   144  		return nil, errors.Annotate(err, `bad "output_properties" struct mask`).Err()
   145  	}
   146  	req, err := structFilter("infra.buildbucket.requested_properties", bm.RequestedProperties)
   147  	if err != nil {
   148  		return nil, errors.Annotate(err, `bad "requested_properties" struct mask`).Err()
   149  	}
   150  
   151  	// Construct the overall pb.Build mask.
   152  	var m *mask.Mask
   153  	if fm == &defaultFieldMask {
   154  		// An optimization for the common case, to avoid constructing mask.Mask all
   155  		// the time.
   156  		m = DefaultBuildMask.m
   157  	} else {
   158  		var err error
   159  		if m, err = mask.FromFieldMask(fm, &buildPrototype, false, false); err != nil {
   160  			return nil, err
   161  		}
   162  	}
   163  
   164  	// We want to support only field masks compatible with Go protobuf library.
   165  	// Note that "go.chromium.org/luci/common/proto/mask" implements a superset
   166  	// of this functionality. It also returns detailed errors. So we used it first
   167  	// to reject obviously invalid masks (e.g. referring to unknown fields) with
   168  	// nice error messages, and use a blunt IsValid check below to reject no
   169  	// longer supported non-protobuf compatible masks.
   170  	if !fm.IsValid(&buildPrototype) {
   171  		return nil, errors.Reason(
   172  			"the extended field mask syntax is no longer supported, " +
   173  				"use the standard one: " +
   174  				"https://pkg.go.dev/google.golang.org/protobuf/types/known/fieldmaskpb#FieldMask",
   175  		).Err()
   176  	}
   177  
   178  	return &BuildMask{
   179  		m:            m,
   180  		in:           in,
   181  		out:          out,
   182  		req:          req,
   183  		stepStatuses: stepStatuses,
   184  	}, nil
   185  }
   186  
   187  // newLegacyBuildMask constructs BuildMask from legacy `fields` field.
   188  func newLegacyBuildMask(legacyPrefix string, fields *fieldmaskpb.FieldMask) (*BuildMask, error) {
   189  	if len(fields.GetPaths()) == 0 {
   190  		return DefaultBuildMask, nil
   191  	}
   192  	var m *mask.Mask
   193  	var err error
   194  	switch legacyPrefix {
   195  	case "":
   196  		m, err = mask.FromFieldMask(fields, &buildPrototype, false, false)
   197  	case "builds":
   198  		m, err = mask.FromFieldMask(fields, &searchBuildPrototype, false, false)
   199  		if err == nil {
   200  			m, err = m.Submask("builds.*")
   201  		}
   202  	default:
   203  		panic(fmt.Sprintf("unsupported legacy prefix %q", legacyPrefix))
   204  	}
   205  	if err != nil {
   206  		return nil, err
   207  	}
   208  	return &BuildMask{m: m}, nil
   209  }
   210  
   211  // HardcodedBuildMask returns a build mask with given fields.
   212  //
   213  // Panics if some of them are invalid. Intended to be used to initialize
   214  // constants or in tests.
   215  func HardcodedBuildMask(fields ...string) *BuildMask {
   216  	return &BuildMask{m: mask.MustFromReadMask(&buildPrototype, fields...)}
   217  }
   218  
   219  // BuildFieldsWithVisibility returns a list of Build fields that are visible
   220  // with the specified level of read permission. For example, the following:
   221  //
   222  //	BuildFieldsWithVisibility(pb.BuildFieldVisibility_BUILDS_GET_LIMITED_PERMISSION)
   223  //
   224  // will return a list of Build fields (including nested fields) that have been
   225  // annotated with either of the following field options:
   226  //
   227  //	[(visible_with) = BUILDS_GET_LIMITED_PERMISSION]
   228  //	[(visible_with) = BUILDS_LIST_PERMISSION]
   229  //
   230  // Note that visibility permissions are strictly ordered: if a user has the
   231  // GetLimited permission, that implies they also have the List permission.
   232  func BuildFieldsWithVisibility(visibility pb.BuildFieldVisibility) []string {
   233  	paths := make([]string, 0, 16)
   234  	findFieldPathsWithVisibility(buildPrototype.ProtoReflect().Descriptor(), []string{}, visibility, &paths)
   235  	return paths
   236  }
   237  
   238  func findFieldPathsWithVisibility(md protoreflect.MessageDescriptor, path []string, visibility pb.BuildFieldVisibility, outPaths *[]string) {
   239  	fields := md.Fields()
   240  	for i := 0; i < fields.Len(); i++ {
   241  		fd := fields.Get(i)
   242  		name := string(fd.Name())
   243  		opts := fd.Options().(*descriptorpb.FieldOptions)
   244  		fieldVisibility := proto.GetExtension(opts, pb.E_VisibleWith).(pb.BuildFieldVisibility)
   245  		if fieldVisibility.Number() >= visibility.Number() {
   246  			*outPaths = append(*outPaths, strings.Join(append(path, name), "."))
   247  		}
   248  		// Simplifying hack: since we currently only need recursion to depth 1,
   249  		// don't recurse into child messages if there is any path prefix.
   250  		// This allows us to avoid implementing cycle detection.
   251  		// If, in future, we want to give extended access to fields nested more
   252  		// than 1 message deep, this hack will need to be extended.
   253  		// Since field visibility fails closed, this isn't a security risk.
   254  		if len(path) > 0 {
   255  			continue
   256  		}
   257  		if fd.Kind() == protoreflect.MessageKind {
   258  			findFieldPathsWithVisibility(fd.Message(), append(path, name), visibility, outPaths)
   259  		}
   260  	}
   261  }
   262  
   263  // Includes returns true if the given field path is included in the mask
   264  // (either partially or entirely), or the mask includes all fields.
   265  //
   266  // Panics if the fieldPath is invalid.
   267  func (m *BuildMask) Includes(fieldPath string) bool {
   268  	if m.allFields {
   269  		return true
   270  	}
   271  	inc, err := m.m.Includes(fieldPath)
   272  	if err != nil {
   273  		panic(errors.Annotate(err, "bad field path %q", fieldPath).Err())
   274  	}
   275  	return inc != mask.Exclude
   276  }
   277  
   278  // Trim applies the mask to the build in-place.
   279  func (m *BuildMask) Trim(b *pb.Build) error {
   280  	if err := m.m.Trim(b); err != nil {
   281  		return err
   282  	}
   283  	if m.in != nil && b.Input != nil {
   284  		b.Input.Properties = m.in.Apply(b.Input.Properties)
   285  	}
   286  	if m.out != nil && b.Output != nil {
   287  		b.Output.Properties = m.out.Apply(b.Output.Properties)
   288  	}
   289  	if m.req != nil && b.Infra != nil && b.Infra.Buildbucket != nil {
   290  		b.Infra.Buildbucket.RequestedProperties = m.req.Apply(b.Infra.Buildbucket.RequestedProperties)
   291  	}
   292  	if len(m.stepStatuses) > 0 && len(b.Steps) > 0 {
   293  		steps := make([]*pb.Step, 0, len(b.Steps))
   294  		for _, s := range b.Steps {
   295  			if _, ok := m.stepStatuses[s.Status]; ok {
   296  				steps = append(steps, s)
   297  			}
   298  		}
   299  		b.Steps = steps
   300  	}
   301  	return nil
   302  }