go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/quotabeta/admin.go (about)

     1  // Copyright 2022 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 quota
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"crypto/sha256"
    21  	"fmt"
    22  	"strings"
    23  	"text/template"
    24  
    25  	"github.com/gomodule/redigo/redis"
    26  	"google.golang.org/grpc/codes"
    27  	"google.golang.org/protobuf/runtime/protoiface"
    28  
    29  	"go.chromium.org/luci/common/clock"
    30  	"go.chromium.org/luci/common/errors"
    31  	"go.chromium.org/luci/common/logging"
    32  	"go.chromium.org/luci/grpc/appstatus"
    33  
    34  	"go.chromium.org/luci/server/auth"
    35  	pb "go.chromium.org/luci/server/quotabeta/proto"
    36  	"go.chromium.org/luci/server/quotabeta/quotaconfig"
    37  	"go.chromium.org/luci/server/redisconn"
    38  )
    39  
    40  // getEntry is a *template.Template for a Lua script which gets the given quota
    41  // entry from Redis. Should be used after a single updateEntry.
    42  //
    43  // Template variables:
    44  // Var: Name of a Lua variable holding the quota entry in memory.
    45  var getEntry = template.Must(template.New("getEntry").Parse(`
    46  	return {{.Var}}["resources"]
    47  `))
    48  
    49  // Ensure quotaAdmin implements QuotaAdminServer at compile-time.
    50  var _ pb.QuotaAdminServer = &quotaAdmin{}
    51  
    52  // quotaAdmin implements pb.QuotaAdminServer.
    53  type quotaAdmin struct {
    54  }
    55  
    56  // Get returns the available resources for the given policy.
    57  func (*quotaAdmin) Get(ctx context.Context, req *pb.GetRequest) (*pb.QuotaEntry, error) {
    58  	if req.GetPolicy() == "" {
    59  		return nil, appstatus.Errorf(codes.InvalidArgument, "policy is required")
    60  	}
    61  
    62  	now := clock.Now(ctx).Unix()
    63  	cfg := getInterface(ctx)
    64  	rsp := &pb.QuotaEntry{}
    65  
    66  	rsp.Name = req.Policy
    67  	if strings.Contains(req.Policy, "${user}") {
    68  		if req.User == "" {
    69  			return nil, appstatus.BadRequest(errors.New("user not specified"))
    70  		}
    71  		rsp.Name = strings.ReplaceAll(rsp.Name, "${user}", req.User)
    72  	}
    73  	rsp.DbName = fmt.Sprintf("entry:%x", sha256.Sum256([]byte(rsp.Name)))
    74  	def, err := cfg.Get(ctx, req.Policy)
    75  	switch {
    76  	case err == quotaconfig.ErrNotFound:
    77  		return nil, appstatus.Errorf(codes.NotFound, "policy %q (db name: %s) not found", rsp.Name, rsp.DbName)
    78  	case err != nil:
    79  		return nil, errors.Annotate(err, "fetching config").Err()
    80  	}
    81  
    82  	conn, err := redisconn.Get(ctx)
    83  	if err != nil {
    84  		return nil, errors.Annotate(err, "establishing connection").Err()
    85  	}
    86  	defer conn.Close()
    87  
    88  	s := bytes.NewBufferString("local entry = {}\n")
    89  	if err := updateEntry.Execute(s, map[string]any{
    90  		"Var":           "entry",
    91  		"Name":          rsp.DbName,
    92  		"Default":       def.Resources,
    93  		"Now":           now,
    94  		"Replenishment": def.Replenishment,
    95  		"Amount":        0,
    96  	}); err != nil {
    97  		return nil, errors.Annotate(err, "rendering template %q", updateEntry.Name()).Err()
    98  	}
    99  	if err := getEntry.Execute(s, map[string]any{
   100  		"Var": "entry",
   101  	}); err != nil {
   102  		return nil, errors.Annotate(err, "rendering template %q", setEntry.Name()).Err()
   103  	}
   104  
   105  	val, err := redis.NewScript(0, s.String()).Do(conn)
   106  	if err != nil {
   107  		return nil, err
   108  	}
   109  	res, ok := val.(int64)
   110  	if !ok {
   111  		return nil, errors.Annotate(err, "expected int64 not %T", val).Err()
   112  	}
   113  	rsp.Resources = res
   114  	return rsp, nil
   115  }
   116  
   117  // overwriteEntry is a *template.Template for a Lua script which sets the given
   118  // quota entry in Redis, overwriting any existing entry.
   119  //
   120  // Template variables:
   121  // Name: Name of the quota entry to update.
   122  // Resources: Number of resources to set.
   123  // Now: Update time in seconds since epoch.
   124  var overwriteEntry = template.Must(template.New("getEntry").Parse(`
   125  	return redis.call("HMSET", "{{.Name}}", "resources", {{.Resources}}, "updated", {{.Now}})
   126  `))
   127  
   128  // Set updates the available resources for the given policy.
   129  func (*quotaAdmin) Set(ctx context.Context, req *pb.SetRequest) (*pb.QuotaEntry, error) {
   130  	switch {
   131  	case req.GetPolicy() == "":
   132  		return nil, appstatus.Errorf(codes.InvalidArgument, "policy is required")
   133  	case req.Resources < 0:
   134  		return nil, appstatus.Errorf(codes.InvalidArgument, "resources must not be negative")
   135  	}
   136  
   137  	now := clock.Now(ctx).Unix()
   138  	cfg := getInterface(ctx)
   139  	rsp := &pb.QuotaEntry{}
   140  
   141  	rsp.Name = req.Policy
   142  	if strings.Contains(req.Policy, "${user}") {
   143  		if req.User == "" {
   144  			return nil, appstatus.BadRequest(errors.New("user not specified"))
   145  		}
   146  		rsp.Name = strings.ReplaceAll(rsp.Name, "${user}", req.User)
   147  	}
   148  	rsp.DbName = fmt.Sprintf("entry:%x", sha256.Sum256([]byte(rsp.Name)))
   149  	def, err := cfg.Get(ctx, req.Policy)
   150  	switch {
   151  	case err == quotaconfig.ErrNotFound:
   152  		return nil, appstatus.Errorf(codes.NotFound, "policy %q (db name: %s) not found", rsp.Name, rsp.DbName)
   153  	case err != nil:
   154  		return nil, errors.Annotate(err, "fetching config").Err()
   155  	}
   156  	rsp.Resources = req.Resources
   157  	if rsp.Resources > def.Resources {
   158  		rsp.Resources = def.Resources
   159  	}
   160  
   161  	conn, err := redisconn.Get(ctx)
   162  	if err != nil {
   163  		return nil, errors.Annotate(err, "establishing connection").Err()
   164  	}
   165  	defer conn.Close()
   166  
   167  	s := bytes.NewBufferString("")
   168  	if err := overwriteEntry.Execute(s, map[string]any{
   169  		"Name":      rsp.DbName,
   170  		"Resources": rsp.Resources,
   171  		"Now":       now,
   172  	}); err != nil {
   173  		return nil, errors.Annotate(err, "rendering template %q", overwriteEntry.Name()).Err()
   174  	}
   175  
   176  	if _, err := redis.NewScript(0, s.String()).Do(conn); err != nil {
   177  		return nil, err
   178  	}
   179  	return rsp, nil
   180  }
   181  
   182  // NewQuotaAdminServer returns a pb.QuotaAdminServer with ACLs limited to the
   183  // given groups. Readers have access to Get.
   184  // TODO(crbug/1280055): Add more admin methods, detail access here.
   185  func NewQuotaAdminServer(readerGroup, writerGroup string) pb.QuotaAdminServer {
   186  	writers := []string{writerGroup}
   187  	readers := append(writers, readerGroup)
   188  	return &pb.DecoratedQuotaAdmin{
   189  		// Prelude restricts access to the given groups.
   190  		Prelude: func(ctx context.Context, methodName string, _ protoiface.MessageV1) (context.Context, error) {
   191  			groups := writers
   192  			if methodName == "Get" {
   193  				groups = readers
   194  			}
   195  			switch is, err := auth.IsMember(ctx, groups...); {
   196  			case err != nil:
   197  				return ctx, errors.Annotate(err, "auth.IsMember").Err()
   198  			case is:
   199  				logging.Debugf(ctx, "%s called %q", auth.CurrentIdentity(ctx), methodName)
   200  				return ctx, nil
   201  			default:
   202  				return ctx, appstatus.Errorf(codes.PermissionDenied, "unauthorized user %s", auth.CurrentIdentity(ctx))
   203  			}
   204  		},
   205  
   206  		Service: &quotaAdmin{},
   207  
   208  		// Postlude logs non-GRPC errors, and returns them as gRPC internal errors.
   209  		Postlude: func(ctx context.Context, _ string, _ protoiface.MessageV1, err error) error {
   210  			return appstatus.GRPCifyAndLog(ctx, err)
   211  		},
   212  	}
   213  }