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 }