go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/luci_notify/rpc/alerts.go (about)

     1  // Copyright 2024 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 contains the RPC handlers for the LUCI Notify service.
    16  package rpc
    17  
    18  import (
    19  	"context"
    20  	"fmt"
    21  	"regexp"
    22  
    23  	"cloud.google.com/go/spanner"
    24  	"google.golang.org/grpc/codes"
    25  	"google.golang.org/protobuf/types/known/timestamppb"
    26  
    27  	"go.chromium.org/luci/auth/identity"
    28  	"go.chromium.org/luci/common/errors"
    29  	"go.chromium.org/luci/grpc/appstatus"
    30  	"go.chromium.org/luci/server/auth"
    31  	"go.chromium.org/luci/server/span"
    32  
    33  	pb "go.chromium.org/luci/luci_notify/api/service/v1"
    34  	"go.chromium.org/luci/luci_notify/internal/alerts"
    35  )
    36  
    37  type alertsServer struct{}
    38  
    39  var _ pb.AlertsServer = &alertsServer{}
    40  
    41  // NewAlertsServer creates a new server to handle Alerts requests.
    42  func NewAlertsServer() *pb.DecoratedAlerts {
    43  	return &pb.DecoratedAlerts{
    44  		Prelude:  checkAllowedPrelude,
    45  		Service:  &alertsServer{},
    46  		Postlude: gRPCifyAndLogPostlude,
    47  	}
    48  }
    49  
    50  // toAlertProto converts an alerts.Alert value to a pb.Alert proto.
    51  func toAlertProto(value *alerts.Alert) *pb.Alert {
    52  	return &pb.Alert{
    53  		Name:         fmt.Sprintf("alerts/%s", value.AlertKey),
    54  		Bug:          value.Bug,
    55  		SilenceUntil: value.SilenceUntil,
    56  		ModifyTime:   timestamppb.New(value.ModifyTime),
    57  		Etag:         value.Etag(),
    58  	}
    59  }
    60  
    61  // BatchGetAlerts gets a number of alerts by name.
    62  func (*alertsServer) BatchGetAlerts(ctx context.Context, request *pb.BatchGetAlertsRequest) (*pb.BatchGetAlertsResponse, error) {
    63  	keys := []string{}
    64  	for i, name := range request.Names {
    65  		key, err := parseAlertName(name)
    66  		if err != nil {
    67  			return nil, invalidArgumentError(errors.Annotate(err, "name[%v]", i).Err())
    68  		}
    69  		keys = append(keys, key)
    70  	}
    71  	alerts, err := alerts.ReadBatch(span.Single(ctx), keys)
    72  	if err != nil {
    73  		return nil, errors.Annotate(err, "reading alerts").Err()
    74  	}
    75  	response := &pb.BatchGetAlertsResponse{}
    76  	for _, alert := range alerts {
    77  		response.Alerts = append(response.Alerts, toAlertProto(alert))
    78  	}
    79  	return response, nil
    80  }
    81  
    82  // BatchUpdateAlerts updates a number of alerts in a single transaction.
    83  // Note that although AIP-134 specifies that Update should not succeed if the entity does not exist,
    84  // we consider every possible alert to already exist and we are only backing it with a sparse array.
    85  func (*alertsServer) BatchUpdateAlerts(ctx context.Context, request *pb.BatchUpdateAlertsRequest) (*pb.BatchUpdateAlertsResponse, error) {
    86  	hasWriteAccess, err := auth.IsMember(ctx, luciNotifyWriteAccessGroup)
    87  	if err != nil {
    88  		return nil, errors.Annotate(err, "checking write group membership").Err()
    89  	}
    90  	// TODO: Once alerts are moved to LUCI Notify from SOM we need to do tighter ACL checks here,
    91  	// i.e. check that the user has some permission to the builder that the alert is for.
    92  	if !hasWriteAccess {
    93  		if auth.CurrentIdentity(ctx).Kind() == identity.Anonymous {
    94  			return nil, permissionDeniedError(errors.New("please log in before updating alerts"))
    95  		}
    96  		return nil, permissionDeniedError(errors.New("you do not have permission to update alerts"))
    97  	}
    98  
    99  	response := &pb.BatchUpdateAlertsResponse{}
   100  	keys := []string{}
   101  	mutations := []*spanner.Mutation{}
   102  	for i, r := range request.Requests {
   103  		key, err := parseAlertName(r.Alert.Name)
   104  		if err != nil {
   105  			return nil, invalidArgumentError(errors.Annotate(err, "alerts[%v]: name", i).Err())
   106  		}
   107  		keys = append(keys, key)
   108  		a := &alerts.Alert{
   109  			AlertKey:     key,
   110  			Bug:          r.Alert.Bug,
   111  			SilenceUntil: r.Alert.SilenceUntil,
   112  		}
   113  		m, err := alerts.Put(a)
   114  		if err != nil {
   115  			return nil, invalidArgumentError(errors.Annotate(err, "alerts[%v]", i).Err())
   116  		}
   117  		mutations = append(mutations, m)
   118  		response.Alerts = append(response.Alerts, &pb.Alert{
   119  			Name:         fmt.Sprintf("alerts/%s", a.AlertKey),
   120  			Bug:          a.Bug,
   121  			SilenceUntil: a.SilenceUntil,
   122  			Etag:         a.Etag(),
   123  		})
   124  	}
   125  
   126  	ts, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
   127  		currentAlerts, err := alerts.ReadBatch(ctx, keys)
   128  		if err != nil {
   129  			return errors.Annotate(err, "reading existing alert values").Err()
   130  		}
   131  		for i := 0; i < len(request.Requests); i++ {
   132  			if request.Requests[i].Alert.Etag == "" {
   133  				continue
   134  			}
   135  			if request.Requests[i].Alert.Etag != currentAlerts[i].Etag() {
   136  				return abortedError(errors.New(fmt.Sprintf("etag does not match on alert[%v]", i)))
   137  			}
   138  		}
   139  		span.BufferWrite(ctx, mutations...)
   140  		return nil
   141  	})
   142  	if err != nil {
   143  		return nil, errors.Annotate(err, "apply update alerts to spanner").Err()
   144  	}
   145  
   146  	for _, a := range response.Alerts {
   147  		a.ModifyTime = timestamppb.New(ts)
   148  	}
   149  	return response, nil
   150  }
   151  
   152  var alertNameRE = regexp.MustCompile(`^alerts/(` + alerts.AlertKeyExpression + `)$`)
   153  
   154  // parseAlertName parses an alert resource name into its constituent ID
   155  // parts.
   156  func parseAlertName(name string) (key string, err error) {
   157  	if name == "" {
   158  		return "", errors.Reason("must be specified").Err()
   159  	}
   160  	match := alertNameRE.FindStringSubmatch(name)
   161  	if match == nil {
   162  		return "", errors.Reason("expected format: %s", alertNameRE).Err()
   163  	}
   164  	return match[1], nil
   165  }
   166  
   167  // invalidArgumentError annotates err as having an invalid argument.
   168  // The error message is shared with the requester as is.
   169  //
   170  // Note that this differs from FailedPrecondition. It indicates arguments
   171  // that are problematic regardless of the state of the system
   172  // (e.g., a malformed file name).
   173  func invalidArgumentError(err error) error {
   174  	return appstatus.Attachf(err, codes.InvalidArgument, "%s", err)
   175  }
   176  
   177  // permissionDeniedError annotates err as being denied (HTTP 403).
   178  // The error message is shared with the requester as is.
   179  func permissionDeniedError(err error) error {
   180  	return appstatus.Attachf(err, codes.PermissionDenied, "%s", err)
   181  }
   182  
   183  // abortedError annotates err as being aborted.
   184  // The error message is shared with the requester as is.
   185  func abortedError(err error) error {
   186  	return appstatus.Attachf(err, codes.Aborted, "%s", err)
   187  }