go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/rpc/query_consoles.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 20 "go.chromium.org/luci/auth/identity" 21 "go.chromium.org/luci/common/errors" 22 "go.chromium.org/luci/common/pagination" 23 "go.chromium.org/luci/common/pagination/dscursor" 24 "go.chromium.org/luci/gae/service/datastore" 25 "go.chromium.org/luci/grpc/appstatus" 26 "go.chromium.org/luci/milo/internal/projectconfig" 27 "go.chromium.org/luci/milo/internal/utils" 28 projectconfigpb "go.chromium.org/luci/milo/proto/projectconfig" 29 milopb "go.chromium.org/luci/milo/proto/v1" 30 "go.chromium.org/luci/milo/protoutil" 31 "go.chromium.org/luci/server/auth" 32 "google.golang.org/grpc/codes" 33 ) 34 35 var queryConsolesPageTokenVault = dscursor.NewVault([]byte("luci.milo.v1.MiloInternal.QueryConsoles")) 36 37 var queryConsolesPageSize = PageSizeLimiter{ 38 Max: 100, 39 Default: 25, 40 } 41 42 // QueryConsoles implements milopb.MiloInternal service 43 func (s *MiloInternalService) QueryConsoles(ctx context.Context, req *milopb.QueryConsolesRequest) (_ *milopb.QueryConsolesResponse, err error) { 44 // Validate request. 45 err = validatesQueryConsolesRequest(req) 46 if err != nil { 47 return nil, appstatus.BadRequest(err) 48 } 49 50 allowed := true 51 // Theoretically, we don't need to protect against unauthorized access since 52 // we filters out forbidden projects in the datastore query below. Checking it 53 // here allows us to return 404 instead of 200 with an empty response, also 54 // prevents unnecessary datastore query. 55 if allowed && req.GetPredicate().GetProject() != "" { 56 allowed, err = projectconfig.IsAllowed(ctx, req.Predicate.Project) 57 if err != nil { 58 return nil, err 59 } 60 } 61 // Without this, user may be able to tell which accessible console contains an 62 // external builder that they don't have access to. This is not necessarily 63 // wrong as the access model around this is not well defined yet. But it's 64 // safer to use a stricter restriction. 65 if allowed && req.GetPredicate().GetBuilder() != nil { 66 allowed, err = projectconfig.IsAllowed(ctx, req.Predicate.Builder.Project) 67 if err != nil { 68 return nil, err 69 } 70 } 71 if !allowed { 72 if auth.CurrentIdentity(ctx) == identity.AnonymousIdentity { 73 return nil, appstatus.Error(codes.Unauthenticated, "not logged in ") 74 } 75 return nil, appstatus.Error(codes.PermissionDenied, "no access to the project") 76 } 77 78 // Decode cursor from page token. 79 cur, err := queryConsolesPageTokenVault.Cursor(ctx, req.PageToken) 80 switch err { 81 case pagination.ErrInvalidPageToken: 82 return nil, appstatus.Error(codes.InvalidArgument, "invalid page token") 83 case nil: 84 // Continue 85 default: 86 return nil, err 87 } 88 89 pageSize := int(queryConsolesPageSize.Adjust(req.PageSize)) 90 91 isProjectAllowed := make(map[string]bool) 92 93 // Construct query. 94 q := datastore.NewQuery("Console") 95 if req.GetPredicate().GetProject() != "" { 96 q = q.Ancestor(datastore.MakeKey(ctx, "Project", req.Predicate.Project)) 97 } else { 98 // Ordinal is only useful within a project. If the consoles are not limited 99 // to a single project, sort them by projects first. 100 q = q.Order("__key__") 101 } 102 if req.GetPredicate().GetBuilder() != nil { 103 q = q.Eq("Builders", utils.LegacyBuilderIDString(req.Predicate.Builder)) 104 } 105 q = q.Order("Ordinal").Start(cur) 106 107 // Query consoles. 108 consoles := make([]*projectconfigpb.Console, 0, pageSize) 109 var nextCursor datastore.Cursor 110 err = datastore.Run(ctx, q, func(con *projectconfig.Console, getCursor datastore.CursorCB) error { 111 proj := con.ProjectID() 112 113 isAllowed, ok := isProjectAllowed[proj] 114 if !ok { 115 var err error 116 isAllowed, err = projectconfig.IsAllowed(ctx, proj) 117 if err != nil { 118 return err 119 } 120 isProjectAllowed[proj] = isAllowed 121 } 122 if !isAllowed { 123 return nil 124 } 125 126 // Use the project:@root as realm if the realm is not yet defined for the 127 // console. 128 // TODO(crbug/1110314): remove this once all consoles have their realm 129 // populated. Also implement realm based authentication (instead of project 130 // based). 131 realm := proj + ":@root" 132 if con.Realm != "" { 133 realm = con.Realm 134 } 135 136 consoles = append(consoles, &projectconfigpb.Console{ 137 Id: con.ID, 138 Realm: realm, 139 }) 140 141 if len(consoles) == pageSize { 142 nextCursor, err = getCursor() 143 if err != nil { 144 return err 145 } 146 147 return datastore.Stop 148 } 149 return nil 150 }) 151 if err != nil { 152 return nil, err 153 } 154 155 // Construct the next page token. 156 nextPageToken, err := queryConsolesPageTokenVault.PageToken(ctx, nextCursor) 157 if err != nil { 158 return nil, err 159 } 160 161 return &milopb.QueryConsolesResponse{ 162 Consoles: consoles, 163 NextPageToken: nextPageToken, 164 }, nil 165 } 166 167 func validatesQueryConsolesRequest(req *milopb.QueryConsolesRequest) error { 168 err := protoutil.ValidateConsolePredicate(req.Predicate) 169 if err != nil { 170 return errors.Annotate(err, "predicate").Err() 171 } 172 173 if req.PageSize < 0 { 174 return errors.Reason("page_size can not be negative").Err() 175 } 176 177 return nil 178 }