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 }