go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/rpc/query_console_snapshots.go (about) 1 // Copyright 2023 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 "strings" 20 21 "google.golang.org/grpc/codes" 22 23 "go.chromium.org/luci/auth/identity" 24 "go.chromium.org/luci/buildbucket/bbperms" 25 buildbucketpb "go.chromium.org/luci/buildbucket/proto" 26 bbprotoutil "go.chromium.org/luci/buildbucket/protoutil" 27 bbv1 "go.chromium.org/luci/common/api/buildbucket/buildbucket/v1" 28 "go.chromium.org/luci/common/data/stringset" 29 "go.chromium.org/luci/common/errors" 30 "go.chromium.org/luci/common/pagination" 31 "go.chromium.org/luci/common/pagination/dscursor" 32 "go.chromium.org/luci/gae/service/datastore" 33 "go.chromium.org/luci/grpc/appstatus" 34 "go.chromium.org/luci/milo/internal/model" 35 "go.chromium.org/luci/milo/internal/projectconfig" 36 "go.chromium.org/luci/milo/internal/utils" 37 projectconfigpb "go.chromium.org/luci/milo/proto/projectconfig" 38 milopb "go.chromium.org/luci/milo/proto/v1" 39 "go.chromium.org/luci/milo/protoutil" 40 "go.chromium.org/luci/server/auth" 41 "go.chromium.org/luci/server/auth/realms" 42 ) 43 44 var queryConsoleSnapshotsPageTokenVault = dscursor.NewVault([]byte("luci.milo.v1.MiloInternal.QueryConsoleSnapshots")) 45 46 var queryConsoleSnapshotsPageSize = PageSizeLimiter{ 47 Max: 100, 48 Default: 25, 49 } 50 51 // QueryConsoleSnapshots implements milopb.MiloInternal service 52 func (s *MiloInternalService) QueryConsoleSnapshots(ctx context.Context, req *milopb.QueryConsoleSnapshotsRequest) (_ *milopb.QueryConsoleSnapshotsResponse, err error) { 53 // Validate request. 54 err = validateQueryConsoleSnapshotsRequest(req) 55 if err != nil { 56 return nil, appstatus.BadRequest(err) 57 } 58 59 allowed := true 60 // Theoretically, we don't need to protect against unauthorized access since 61 // we filter out forbidden projects in the datastore query below. Checking it 62 // here allows us to return 404 instead of 200 with an empty response, also 63 // prevents unnecessary datastore query. 64 if allowed && req.GetPredicate().GetProject() != "" { 65 allowed, err = projectconfig.IsAllowed(ctx, req.Predicate.Project) 66 if err != nil { 67 return nil, err 68 } 69 } 70 // Without this, user may be able to tell which accessible console contains an 71 // external builder that they don't have access to. This is not necessarily 72 // wrong as the access model around this is not well defined yet. But it's 73 // safer to use a stricter restriction. 74 if allowed && req.GetPredicate().GetBuilder() != nil { 75 allowed, err = projectconfig.IsAllowed(ctx, req.Predicate.Builder.Project) 76 if err != nil { 77 return nil, err 78 } 79 } 80 if !allowed { 81 if auth.CurrentIdentity(ctx) == identity.AnonymousIdentity { 82 return nil, appstatus.Error(codes.Unauthenticated, "not logged in ") 83 } 84 return nil, appstatus.Error(codes.PermissionDenied, "no access to the project") 85 } 86 87 // Decode cursor from page token. 88 cur, err := queryConsoleSnapshotsPageTokenVault.Cursor(ctx, req.PageToken) 89 if errors.Is(err, pagination.ErrInvalidPageToken) { 90 return nil, appstatus.Error(codes.InvalidArgument, "invalid page token") 91 } 92 if err != nil { 93 return nil, err 94 } 95 96 pageSize := int(queryConsoleSnapshotsPageSize.Adjust(req.PageSize)) 97 98 isProjectAllowed := make(map[string]bool) 99 checkProjectIsAllowed := func(proj string) (bool, error) { 100 isAllowed, ok := isProjectAllowed[proj] 101 if !ok { 102 var err error 103 isAllowed, err = projectconfig.IsAllowed(ctx, proj) 104 if err != nil { 105 return isAllowed, err 106 } 107 isProjectAllowed[proj] = isAllowed 108 } 109 110 return isAllowed, nil 111 } 112 113 allowedRealms, err := auth.QueryRealms(ctx, bbperms.BuildsList, "", nil) 114 if err != nil { 115 return nil, err 116 } 117 allowedRealmsSet := stringset.NewFromSlice(allowedRealms...) 118 119 // Construct console query. 120 q := datastore.NewQuery("Console").Ancestor(datastore.MakeKey(ctx, "Project", req.Predicate.Project)) 121 if req.GetPredicate().GetBuilder() != nil { 122 q = q.Eq("Builders", utils.LegacyBuilderIDString(req.Predicate.Builder)) 123 } 124 q = q.Order("Ordinal").Start(cur) 125 126 // Query consoles. 127 consoles := make([]*projectconfigpb.Console, 0, pageSize) 128 var nextCursor datastore.Cursor 129 err = datastore.Run(ctx, q, func(con *projectconfig.Console, getCursor datastore.CursorCB) error { 130 // Resolve external console. 131 if con.Def.ExternalId != "" { 132 // If the user doesn't have access to the original project, skip the 133 // external console. 134 sourceProj := con.ProjectID() 135 if allowed, err := checkProjectIsAllowed(sourceProj); err != nil || !allowed { 136 return err 137 } 138 139 con.Parent = datastore.MakeKey(ctx, "Project", con.Def.ExternalProject) 140 con.ID = con.Def.ExternalId 141 if err = datastore.Get(ctx, con); err != nil { 142 return errors.Annotate(err, "failed to resolve external console").Err() 143 } 144 } 145 146 proj := con.ProjectID() 147 if allowed, err := checkProjectIsAllowed(proj); err != nil || !allowed { 148 return err 149 } 150 151 // Use the project:@root as realm if the realm is not yet defined for the 152 // console. 153 // TODO(crbug/1110314): remove this once all consoles have their realm 154 // populated. Also implement realm based authentication (instead of project 155 // based). 156 realm := proj + ":@root" 157 if con.Realm != "" { 158 realm = con.Realm 159 } 160 161 consoles = append(consoles, &projectconfigpb.Console{ 162 Id: con.ID, 163 Name: con.Def.Name, 164 RepoUrl: con.Def.RepoUrl, 165 Realm: realm, 166 Builders: con.Def.AllowedBuilders(allowedRealmsSet), 167 }) 168 169 if len(consoles) == pageSize { 170 nextCursor, err = getCursor() 171 if err != nil { 172 return err 173 } 174 175 return datastore.Stop 176 } 177 return nil 178 }) 179 if err != nil { 180 return nil, err 181 } 182 183 // Construct the next page token. 184 nextPageToken, err := queryConsoleSnapshotsPageTokenVault.PageToken(ctx, nextCursor) 185 if err != nil { 186 return nil, err 187 } 188 189 // Keep a map (builder ID -> builder summary) to track duplicates and 190 // enable easier look up. 191 builderSummariesMap := map[string]*model.BuilderSummary{} 192 193 // Get a list of unique builder summaries. 194 builderSummariesList := []*model.BuilderSummary{} 195 for _, con := range consoles { 196 for _, builder := range con.Builders { 197 bid := bbprotoutil.FormatBuilderID(builder.Id) 198 legacyBid := utils.LegacyBuilderIDString(builder.Id) 199 _, ok := builderSummariesMap[bid] 200 if !ok { 201 bs := &model.BuilderSummary{BuilderID: legacyBid} 202 builderSummariesMap[bid] = bs 203 builderSummariesList = append(builderSummariesList, bs) 204 } 205 } 206 } 207 208 // Load all the unique builder summaries from the datastore. 209 err = datastore.Get(ctx, builderSummariesList) 210 var errs errors.MultiError 211 if errors.As(err, &errs) { 212 criticalErrs := errors.NewLazyMultiError(len(errs)) 213 for i, e := range errs { 214 // It's Ok if a builder has no record. 215 // Filter out and ignore `datastore.ErrNoSuchEntity`. 216 if errors.Is(e, datastore.ErrNoSuchEntity) { 217 e = nil 218 } 219 criticalErrs.Assign(i, e) 220 } 221 if err := criticalErrs.Get(); err != nil { 222 return nil, err 223 } 224 } 225 226 // Construct console snapshots from the builder summaries. 227 consoleSnapshots := make([]*milopb.ConsoleSnapshot, 0, len(consoles)) 228 for _, con := range consoles { 229 builderSnapshots := make([]*milopb.BuilderSnapshot, 0, len(con.Builders)) 230 for _, builder := range con.Builders { 231 builderID := bbprotoutil.FormatBuilderID(builder.Id) 232 builderSummary := builderSummariesMap[builderID] 233 234 // The builder has no record because it's new or has been inactive for too 235 // long. Use a nil build to represent that. 236 if builderSummary.LastFinishedBuildID == "" { 237 builderSnapshots = append(builderSnapshots, &milopb.BuilderSnapshot{ 238 Builder: builder.Id, 239 Build: nil, 240 }) 241 continue 242 } 243 244 buildAddress := strings.TrimPrefix(builderSummary.LastFinishedBuildID, "buildbucket/") 245 buildID, _, _, _, buildNum, err := bbv1.ParseBuildAddress(buildAddress) 246 if err != nil { 247 return nil, err 248 } 249 250 builderSnapshots = append(builderSnapshots, &milopb.BuilderSnapshot{ 251 Builder: builder.Id, 252 Build: &buildbucketpb.Build{ 253 Id: buildID, 254 Builder: builder.Id, 255 Number: int32(buildNum), 256 Status: builderSummary.LastFinishedStatus.ToBuildbucket(), 257 }, 258 }) 259 } 260 consoleSnapshots = append(consoleSnapshots, &milopb.ConsoleSnapshot{ 261 Console: con, 262 BuilderSnapshots: builderSnapshots, 263 }) 264 } 265 266 return &milopb.QueryConsoleSnapshotsResponse{ 267 Snapshots: consoleSnapshots, 268 NextPageToken: nextPageToken, 269 }, nil 270 } 271 272 func validateQueryConsoleSnapshotsRequest(req *milopb.QueryConsoleSnapshotsRequest) error { 273 err := protoutil.ValidateConsolePredicate(req.Predicate) 274 if err != nil { 275 return errors.Annotate(err, "predicate").Err() 276 } 277 278 if req.GetPredicate().GetProject() == "" { 279 return errors.Reason("predicate.project is required").Err() 280 } 281 282 if req.PageSize < 0 { 283 return errors.Reason("page_size can not be negative").Err() 284 } 285 286 return nil 287 } 288 289 func init() { 290 bbperms.BuildsList.AddFlags(realms.UsedInQueryRealms) 291 }