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  }