go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/config/server/cfgmodule/module.go (about)

     1  // Copyright 2020 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 cfgmodule
    16  
    17  import (
    18  	"context"
    19  	"flag"
    20  	"fmt"
    21  	"net/http"
    22  	"strings"
    23  
    24  	"go.chromium.org/luci/auth/identity"
    25  	"go.chromium.org/luci/common/errors"
    26  	"go.chromium.org/luci/common/logging"
    27  	"go.chromium.org/luci/common/proto/config"
    28  	"go.chromium.org/luci/common/retry/transient"
    29  	"go.chromium.org/luci/config/cfgclient"
    30  	"go.chromium.org/luci/config/validation"
    31  	"go.chromium.org/luci/config/vars"
    32  	"go.chromium.org/luci/server/auth"
    33  	"go.chromium.org/luci/server/auth/signing"
    34  	"go.chromium.org/luci/server/module"
    35  	"go.chromium.org/luci/server/router"
    36  	"google.golang.org/grpc/credentials"
    37  )
    38  
    39  // ModuleName can be used to refer to this module when declaring dependencies.
    40  var ModuleName = module.RegisterName("go.chromium.org/luci/config/server/cfgmodule")
    41  
    42  // A scope expected in access tokens from the LUCI Config service.
    43  const configValidationAuthScope = "https://www.googleapis.com/auth/userinfo.email"
    44  
    45  // ModuleOptions contain configuration of the LUCI Config server module.
    46  type ModuleOptions struct {
    47  	// ServiceHost is a hostname of a LUCI Config service to use.
    48  	//
    49  	// If given, indicates configs should be fetched from the LUCI Config service.
    50  	// Not compatible with LocalDir.
    51  	ServiceHost string
    52  
    53  	// LocalDir is a file system directory to fetch configs from instead of
    54  	// a LUCI Config service.
    55  	//
    56  	// See https://godoc.org/go.chromium.org/luci/config/impl/filesystem for the
    57  	// expected layout of this directory.
    58  	//
    59  	// Useful when running locally in development mode. Not compatible with
    60  	// ServiceHost.
    61  	LocalDir string
    62  
    63  	// Vars is a var set to use to render config set names.
    64  	//
    65  	// If nil, the module uses global &vars.Vars. This is usually what you want.
    66  	//
    67  	// During the initialization the module registers the following vars:
    68  	//   ${appid}: value of -cloud-project server flag.
    69  	//   ${config_service_appid}: app ID of the LUCI Config service.
    70  	Vars *vars.VarSet
    71  
    72  	// Rules is a rule set to use for the config validation.
    73  	//
    74  	// If nil, the module uses global &validation.Rules. This is usually what
    75  	// you want.
    76  	Rules *validation.RuleSet
    77  }
    78  
    79  // Register registers the command line flags.
    80  func (o *ModuleOptions) Register(f *flag.FlagSet) {
    81  	f.StringVar(
    82  		&o.ServiceHost,
    83  		"config-service-host",
    84  		o.ServiceHost,
    85  		`A hostname of a LUCI Config service to use (not compatible with -config-local-dir)`,
    86  	)
    87  	f.StringVar(
    88  		&o.LocalDir,
    89  		"config-local-dir",
    90  		o.LocalDir,
    91  		`A file system directory to fetch configs from (not compatible with -config-service-host)`,
    92  	)
    93  }
    94  
    95  // NewModule returns a server module that exposes LUCI Config validation
    96  // endpoints.
    97  func NewModule(opts *ModuleOptions) module.Module {
    98  	if opts == nil {
    99  		opts = &ModuleOptions{}
   100  	}
   101  	return &serverModule{opts: opts}
   102  }
   103  
   104  // NewModuleFromFlags is a variant of NewModule that initializes options through
   105  // command line flags.
   106  //
   107  // Calling this function registers flags in flag.CommandLine. They are usually
   108  // parsed in server.Main(...).
   109  func NewModuleFromFlags() module.Module {
   110  	opts := &ModuleOptions{}
   111  	opts.Register(flag.CommandLine)
   112  	return NewModule(opts)
   113  }
   114  
   115  // serverModule implements module.Module.
   116  type serverModule struct {
   117  	opts *ModuleOptions
   118  }
   119  
   120  // Name is part of module.Module interface.
   121  func (*serverModule) Name() module.Name {
   122  	return ModuleName
   123  }
   124  
   125  // Dependencies is part of module.Module interface.
   126  func (*serverModule) Dependencies() []module.Dependency {
   127  	return nil
   128  }
   129  
   130  // Initialize is part of module.Module interface.
   131  func (m *serverModule) Initialize(ctx context.Context, host module.Host, opts module.HostOptions) (context.Context, error) {
   132  	if m.opts.Vars == nil {
   133  		m.opts.Vars = &vars.Vars
   134  	}
   135  	if m.opts.Rules == nil {
   136  		m.opts.Rules = &validation.Rules
   137  	}
   138  	m.registerVars(opts)
   139  
   140  	// Instantiate an appropriate client based on options.
   141  	client, err := cfgclient.New(ctx, cfgclient.Options{
   142  		Vars:        m.opts.Vars,
   143  		ServiceHost: m.opts.ServiceHost,
   144  		ConfigsDir:  m.opts.LocalDir,
   145  		ClientFactory: func(ctx context.Context) (*http.Client, error) {
   146  			t, err := auth.GetRPCTransport(ctx, auth.AsSelf, auth.WithScopes(auth.CloudOAuthScopes...))
   147  			if err != nil {
   148  				return nil, err
   149  			}
   150  			return &http.Client{Transport: t}, nil
   151  		},
   152  		GetPerRPCCredsFn: func(ctx context.Context) (credentials.PerRPCCredentials, error) {
   153  			creds, err := auth.GetPerRPCCredentials(ctx,
   154  				auth.AsSelf,
   155  				auth.WithIDTokenAudience("https://"+m.opts.ServiceHost),
   156  			)
   157  			if err != nil {
   158  				return nil, errors.Annotate(err, "failed to get credentials to access %s", host).Err()
   159  			}
   160  			return creds, nil
   161  		},
   162  		UserAgent: opts.CloudProject,
   163  	})
   164  	if err != nil {
   165  		return nil, err
   166  	}
   167  
   168  	// Make it available in the server handlers.
   169  	ctx = cfgclient.Use(ctx, client)
   170  
   171  	host.RegisterCleanup(func(ctx context.Context) {
   172  		if err := client.Close(); err != nil {
   173  			logging.Warningf(ctx, "Failed to close the config client: %s", err)
   174  		}
   175  	})
   176  
   177  	// Enable authentication and authorization for the validation endpoint only
   178  	// when running in production (i.e. not on a developer workstation).
   179  	var middleware router.MiddlewareChain
   180  	if opts.Prod {
   181  		middleware = router.NewMiddlewareChain(
   182  			(&auth.Authenticator{
   183  				Methods: []auth.Method{
   184  					&auth.GoogleOAuth2Method{
   185  						Scopes: []string{configValidationAuthScope},
   186  					},
   187  				},
   188  			}).GetMiddleware(),
   189  			m.authorizeConfigService,
   190  		)
   191  	}
   192  
   193  	// Install the validation endpoint that will be called by the legacy
   194  	// LUCI Config.
   195  	// TODO(yiwzhang): Remove after all apps using LUCI Server Framework is
   196  	// redeployed and the legacy LUCI Config service has been turned down.
   197  	InstallHandlers(host.Routes(), middleware, m.opts.Rules)
   198  
   199  	// Register the prpc `config.Consumer` service that handles configs
   200  	// validation.
   201  	config.RegisterConsumerServer(host, &ConsumerServer{
   202  		Rules: m.opts.Rules,
   203  		GetConfigServiceAccountFn: func(ctx context.Context) (string, error) {
   204  			// TODO(yiwzhang): Remove this after the service host pointing to the new
   205  			// LUCI Config service. For now, hardcode the expected service account
   206  			// name when the service is still using legacy LUCI Config service as
   207  			// config service host. However, it's possible for the service to
   208  			// receive the validation traffic from the new LUCI Config service.
   209  			// So the consumer server needs to allow corresponding new LUCI Config
   210  			// service account.
   211  			if strings.HasSuffix(m.opts.ServiceHost, "appspot.com") {
   212  				if strings.Contains(m.opts.ServiceHost, "dev") {
   213  					return "config-service@luci-config-dev.iam.gserviceaccount.com", nil
   214  				}
   215  				return "config-service@luci-config.iam.gserviceaccount.com", nil
   216  			}
   217  			// Grab the expected service account ID of the LUCI Config service we use.
   218  			info, err := m.configServiceInfo(ctx)
   219  			if err != nil {
   220  				return "", err
   221  			}
   222  			return info.ServiceAccountName, nil
   223  		},
   224  	})
   225  	return ctx, nil
   226  }
   227  
   228  // configServiceInfo fetches LUCI Config service account name and app ID.
   229  //
   230  // Returns an error if ServiceHost is unset.
   231  func (m *serverModule) configServiceInfo(ctx context.Context) (*signing.ServiceInfo, error) {
   232  	if m.opts.ServiceHost == "" {
   233  		return nil, errors.Reason("-config-service-host is not set").Err()
   234  	}
   235  	return signing.FetchServiceInfoFromLUCIService(ctx, "https://"+m.opts.ServiceHost)
   236  }
   237  
   238  // registerVars populates the var set with predefined vars that can be used
   239  // in config patterns.
   240  func (m *serverModule) registerVars(opts module.HostOptions) {
   241  	m.opts.Vars.Register("appid", func(context.Context) (string, error) {
   242  		if opts.CloudProject == "" {
   243  			return "", fmt.Errorf("can't resolve ${appid}: -cloud-project is not set")
   244  		}
   245  		return opts.CloudProject, nil
   246  	})
   247  	m.opts.Vars.Register("config_service_appid", func(ctx context.Context) (string, error) {
   248  		info, err := m.configServiceInfo(ctx)
   249  		if err != nil {
   250  			return "", errors.Annotate(err, "can't resolve ${config_service_appid}").Err()
   251  		}
   252  		return info.AppID, nil
   253  	})
   254  }
   255  
   256  // authorizeConfigService is a middleware that passes the request only if it
   257  // came from LUCI Config service.
   258  func (m *serverModule) authorizeConfigService(c *router.Context, next router.Handler) {
   259  	// Grab the expected service account ID of the LUCI Config service we use.
   260  	info, err := m.configServiceInfo(c.Request.Context())
   261  	if err != nil {
   262  		errors.Log(c.Request.Context(), err)
   263  		if transient.Tag.In(err) {
   264  			http.Error(c.Writer, "Transient error during authorization", http.StatusInternalServerError)
   265  		} else {
   266  			http.Error(c.Writer, "Permission denied (not configured)", http.StatusForbidden)
   267  		}
   268  		return
   269  	}
   270  
   271  	// Check the call is actually from the LUCI Config.
   272  	caller := auth.CurrentIdentity(c.Request.Context())
   273  	if caller.Kind() == identity.User && caller.Value() == info.ServiceAccountName {
   274  		next(c)
   275  	} else {
   276  		// TODO(yiwzhang): Temporarily allow both old and new LUCI Config service
   277  		// account to make the validation request. Revert this change after the old
   278  		// LUCI Config service is fully deprecated and all traffic have been
   279  		// migrated to the new LUCI Config service.
   280  		allowedGroup := "service-accounts-luci-config"
   281  		if strings.Contains(m.opts.ServiceHost, "dev") {
   282  			allowedGroup += "-dev"
   283  		}
   284  		switch yes, err := auth.IsMember(c.Request.Context(), allowedGroup); {
   285  		case err != nil:
   286  			errors.Log(c.Request.Context(), err)
   287  			http.Error(c.Writer, "error during authorization", http.StatusInternalServerError)
   288  		case yes:
   289  			next(c)
   290  		default:
   291  			http.Error(c.Writer, "Permission denied", http.StatusForbidden)
   292  		}
   293  	}
   294  }