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 }