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  }