336 lines
11 KiB
Go
336 lines
11 KiB
Go
package config
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
"github.com/dsa0x/sicher"
|
|
"github.com/go-playground/validator/v10"
|
|
"github.com/go-viper/mapstructure/v2"
|
|
"github.com/gorilla/sessions"
|
|
"github.com/joho/godotenv"
|
|
"github.com/knadh/koanf/parsers/yaml"
|
|
"github.com/knadh/koanf/providers/confmap"
|
|
"github.com/knadh/koanf/providers/env"
|
|
"github.com/knadh/koanf/providers/file"
|
|
"github.com/knadh/koanf/providers/posflag"
|
|
"github.com/knadh/koanf/providers/structs"
|
|
"github.com/knadh/koanf/v2"
|
|
"github.com/samber/lo"
|
|
"github.com/spf13/pflag"
|
|
"go.uber.org/zap"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
const defaultEnvironment = "development"
|
|
|
|
type SicherObject interface {
|
|
SetEnvStyle(string)
|
|
LoadEnv(string, interface{}) error
|
|
}
|
|
|
|
type SicherConfig struct {
|
|
AppSecret string `env:"APP_SECRET" koanf:"app_secret"`
|
|
AppSecretBlock string `env:"APP_SECRET_BLOCK" koanf:"app_secret_block"`
|
|
NocoDBToken string `env:"NOCODB_TOKEN" koanf:"nocodb.api_token"`
|
|
OidcClientId string `env:"OIDC_CLIENT_ID" koanf:"oidc.client_id"`
|
|
OidcClientSecret string `env:"OIDC_CLIENT_SECRET" koanf:"oidc.client_secret"`
|
|
ServiceAccountId string `env:"IDMS_SERVICE_ACCOUNT_ID" koanf:"idms.service_account_id"`
|
|
ServiceAccountPassword string `env:"IDMS_SERVICE_ACCOUNT_PASSWORD" koanf:"idms.service_account_password"`
|
|
}
|
|
|
|
var NewSicherObject = func(environment string, path string) SicherObject {
|
|
return sicher.New(environment, path)
|
|
}
|
|
|
|
type OidcConfig struct {
|
|
ClientId string `koanf:"oidc.client_id" validate:"required"`
|
|
ClientSecret Secret[string] `koanf:"oidc.client_secret" validate:"required"`
|
|
Issuer string `koanf:"oidc.issuer" validate:"required"`
|
|
Scopes []string `koanf:"oidc.scopes" validate:"required"`
|
|
Provider *oidc.Provider
|
|
Verifier *oidc.IDTokenVerifier
|
|
OAuth2 *oauth2.Config
|
|
}
|
|
|
|
type IdmsConfig struct {
|
|
BaseUrl string `koanf:"idms.base_url" validate:"required"`
|
|
CarsGroupId string `koanf:"idms.cars_group_id" validate:"required"`
|
|
Id Secret[string] `koanf:"idms.service_account_id" validate:"required"`
|
|
Password Secret[string] `koanf:"idms.service_account_password" validate:"required"`
|
|
}
|
|
|
|
type NocoDBConfig struct {
|
|
ApiToken Secret[string] `koanf:"nocodb.api_token" validate:"required"`
|
|
BaseUrl string `koanf:"nocodb.base_url" validate:"required"`
|
|
EmployeesTableId string `koanf:"nocodb.employees_table_id" validate:"required"`
|
|
InfractionsTableId string `koanf:"nocodb.infractions_table_id" validate:"required"`
|
|
NegativeInfractionId int `koanf:"nocodb.negative_infraction_id" validate:"required"`
|
|
NoPointsViewId string `koanf:"nocodb.no_points_view_id" validate:"required"`
|
|
}
|
|
|
|
type ApplicationConfig struct {
|
|
AppSecret Secret[string] `koanf:"app_secret"`
|
|
AppSecretBlock Secret[string] `koanf:"app_secret_block"`
|
|
CookieStore *sessions.CookieStore
|
|
Environment string `koanf:"environment" validate:"required"`
|
|
Listen string `koanf:"listen" validate:"required,hostname_port"`
|
|
MatchThreshold int `koanf:"match_threshold" validate:"required"`
|
|
NocoDBConfig NocoDBConfig `koanf:"nocodb" validate:"required"`
|
|
OidcConfig OidcConfig `koanf:"oidc" validate:"required"`
|
|
PublicPath string `koanf:"paths.public"`
|
|
Idms IdmsConfig `koanf:"idms" validate:"required"`
|
|
SessionName string
|
|
ViewPath string `koanf:"paths.views"`
|
|
}
|
|
|
|
type ParsedCliArgs struct {
|
|
ConfigFiles *[]string
|
|
DatabasePasswordPath *string
|
|
Environment *string
|
|
Listen *string
|
|
Version *bool
|
|
}
|
|
|
|
func ParseArgs(version Version, exit func(int), args []string) (*ParsedCliArgs, *pflag.FlagSet) {
|
|
f := pflag.NewFlagSet("config", pflag.ContinueOnError)
|
|
f.Usage = func() {
|
|
fmt.Println(version.AppNameAndVersion(false))
|
|
fmt.Println(f.FlagUsages())
|
|
exit(0)
|
|
}
|
|
|
|
parsedCliArgs := &ParsedCliArgs{
|
|
ConfigFiles: f.StringSliceP("conf", "c", []string{}, "path to one or more .yaml config files"),
|
|
DatabasePasswordPath: f.StringP("db-secret-path", "d", "", "path to database secret"),
|
|
Environment: f.StringP("environment", "e", defaultEnvironment, "Environment for the running binary"),
|
|
Listen: f.StringP("listen", "l", "0.0.0.0:3000", "address and port to listen on"),
|
|
Version: f.Bool("version", false, "Show version"),
|
|
}
|
|
|
|
err := f.Parse(args[1:])
|
|
|
|
if err != nil {
|
|
_, _ = fmt.Fprintf(
|
|
os.Stderr,
|
|
"%s\n\nERROR: %s\n\n%s",
|
|
version.AppNameAndVersion(false), err, f.FlagUsages(),
|
|
)
|
|
exit(1)
|
|
}
|
|
|
|
return parsedCliArgs, f
|
|
}
|
|
|
|
// Load application configuration from config files, command line flags, and environment variables.
|
|
// An error will be returned only if _unexpected_ error happened, such as a file not being found.
|
|
// If the user specified a --help or a --version flag, the application will exit with a 0 status code.
|
|
// If the user specified an unsupported flag, the application will exit with a 1 status code and print the help message.
|
|
func Load(version Version, exit func(int), args []string, logger *zap.Logger) (*ApplicationConfig, error) {
|
|
k := koanf.New(".")
|
|
config := defaultConfig()
|
|
|
|
err := k.Load(confmap.Provider(config, "."), nil)
|
|
|
|
if err != nil {
|
|
log.Fatal("error initializing config", zap.Error(err))
|
|
}
|
|
|
|
parsedCliArgs, f := ParseArgs(version, exit, args)
|
|
|
|
// Load the config files provided in the command line.
|
|
for _, c := range *parsedCliArgs.ConfigFiles {
|
|
err := k.Load(file.Provider(c), yaml.Parser())
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error loading configuration file: %v", err)
|
|
}
|
|
}
|
|
|
|
err = godotenv.Load()
|
|
if err != nil {
|
|
logger.Info("No .env file found")
|
|
}
|
|
|
|
configPath := func(p string) string {
|
|
combinedPath := fmt.Sprintf("%s/%s", k.String("path_prefix"), p)
|
|
return combinedPath
|
|
}
|
|
|
|
// Override with environment variables
|
|
lo.ForEach([]string{envPrefix, pgPrefix, databasePrefix, idmsPrefix, oidcPrefix}, func(prefix string, _ int) {
|
|
if err := k.Load(env.Provider(prefix, "_", mapEnvVarNames(prefix)), nil); err != nil {
|
|
logger.Fatal("error parsing environment variables", zap.Error(err))
|
|
}
|
|
})
|
|
|
|
// Override with command-line values
|
|
if err := k.Load(posflag.Provider(f, ".", k), nil); err != nil {
|
|
return nil, fmt.Errorf("error parsing command-line parameters: %v", err)
|
|
}
|
|
|
|
if err := k.Unmarshal("", &config); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if *parsedCliArgs.Version {
|
|
fmt.Println(version.AppNameAndVersion(true))
|
|
exit(0)
|
|
}
|
|
|
|
var sicherConfig SicherConfig
|
|
|
|
s := NewSicherObject(k.String("environment"), configPath(k.String("paths.credentials")))
|
|
s.SetEnvStyle("yaml") // default is dotenv
|
|
err = s.LoadEnv("", &sicherConfig)
|
|
|
|
cookieStore := sessions.NewCookieStore([]byte(k.String("app_secret")))
|
|
cookieStore.Options.HttpOnly = true
|
|
cookieStore.Options.Secure = *parsedCliArgs.Environment != "dev" && *parsedCliArgs.Environment != "testEnvironment" // Use HTTPS
|
|
cookieStore.Options.SameSite = http.SameSiteLaxMode
|
|
|
|
if err != nil {
|
|
log.Println(err)
|
|
}
|
|
|
|
if err = k.Load(structs.Provider(sicherConfig, "koanf"), nil); err != nil {
|
|
return nil, fmt.Errorf("error parsing sicher parameters: %v", err)
|
|
}
|
|
|
|
var appConfig ApplicationConfig
|
|
|
|
unmarshalConf := koanf.UnmarshalConf{
|
|
DecoderConfig: &mapstructure.DecoderConfig{
|
|
DecodeHook: mapstructure.ComposeDecodeHookFunc(
|
|
SecretFilePathUnmarshalHookFunc(),
|
|
mapstructure.StringToTimeDurationHookFunc(),
|
|
mapstructure.StringToSliceHookFunc(","),
|
|
mapstructure.TextUnmarshallerHookFunc(),
|
|
),
|
|
Metadata: nil,
|
|
Result: &appConfig,
|
|
WeaklyTypedInput: true,
|
|
},
|
|
}
|
|
|
|
if err := k.UnmarshalWithConf("", &config, unmarshalConf); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
appConfig.AppSecret = SecretFromValue(k.String("app_secret"))
|
|
appConfig.AppSecretBlock = SecretFromValue(k.String("app_secret_block"))
|
|
|
|
nocodbConfig := NocoDBConfig{
|
|
ApiToken: SecretFromValue(k.String("nocodb.api_token")),
|
|
BaseUrl: k.String("nocodb.base_url"),
|
|
EmployeesTableId: k.String("nocodb.employees_table_id"),
|
|
InfractionsTableId: k.String("nocodb.infractions_table_id"),
|
|
NegativeInfractionId: k.Int("nocodb.negative_infraction_id"),
|
|
NoPointsViewId: k.String("nocodb.no_points_view_id"),
|
|
}
|
|
|
|
oidcConfig := OidcConfig{
|
|
ClientId: k.String("oidc.client_id"),
|
|
ClientSecret: SecretFromValue(k.String("oidc.client_secret")),
|
|
Issuer: k.String("oidc.issuer"),
|
|
Scopes: k.Strings("oidc.scopes"),
|
|
}
|
|
|
|
if *parsedCliArgs.Environment != "testEnvironment" {
|
|
authProvider, err := oidc.NewProvider(context.Background(), k.String("oidc.issuer"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
oidcConfig.Provider = authProvider
|
|
oidcConfig.Verifier = authProvider.Verifier(&oidc.Config{ClientID: k.String("oidc.client_id")})
|
|
redirectUrl := fmt.Sprintf("%s/auth/callback", k.String("oidc.redirect_url"))
|
|
oidcConfig.OAuth2 = &oauth2.Config{
|
|
ClientID: k.String("oidc.client_id"),
|
|
ClientSecret: k.String("oidc.client_secret"),
|
|
Endpoint: authProvider.Endpoint(),
|
|
RedirectURL: redirectUrl,
|
|
Scopes: k.Strings("oidc.scopes"),
|
|
}
|
|
}
|
|
|
|
appConfig.CookieStore = cookieStore
|
|
appConfig.Environment = *parsedCliArgs.Environment
|
|
appConfig.Idms = IdmsConfig{
|
|
BaseUrl: k.String("idms.base_url"),
|
|
CarsGroupId: k.String("idms.cars_group_id"),
|
|
Id: SecretFromValue(k.String("idms.service_account_id")),
|
|
Password: SecretFromValue(k.String("idms.service_account_password")),
|
|
}
|
|
appConfig.Listen = *parsedCliArgs.Listen
|
|
appConfig.MatchThreshold = k.Int("match_threshold")
|
|
appConfig.NocoDBConfig = nocodbConfig
|
|
appConfig.OidcConfig = oidcConfig
|
|
appConfig.PublicPath = configPath(k.String("paths.public"))
|
|
appConfig.SessionName = "calculate-negative-points"
|
|
appConfig.ViewPath = configPath(k.String("paths.views"))
|
|
|
|
validate := validator.New()
|
|
validate.RegisterCustomTypeFunc(secretTypeTranslator[string], Secret[string]{})
|
|
err = validate.Struct(appConfig)
|
|
|
|
if err != nil {
|
|
log.Println(err)
|
|
fmt.Print("Could not create config")
|
|
exit(1)
|
|
return nil, err
|
|
}
|
|
|
|
return &appConfig, nil
|
|
}
|
|
|
|
const envPrefix = "APP_"
|
|
const pgPrefix = "PG_"
|
|
const databasePrefix = "DATABASE_"
|
|
const idmsPrefix = "IDMS_"
|
|
const oidcPrefix = "OIDC_"
|
|
|
|
/*
|
|
* Rio passes in PG_HOST and PG_PORT so we need to replace the PG string with DATABASE and then not trim the prefix
|
|
*/
|
|
func mapEnvVarNames(prefix string) func(s string) string {
|
|
return func(s string) string {
|
|
if prefix == databasePrefix {
|
|
return strings.ToLower(s)
|
|
}
|
|
|
|
replacedPg := strings.Replace(s, "PG", "DATABASE", -1)
|
|
|
|
lower := strings.ToLower(strings.TrimPrefix(replacedPg, prefix))
|
|
|
|
return lower
|
|
}
|
|
}
|
|
|
|
func defaultConfig() map[string]interface{} {
|
|
dbConfig := map[string]interface{}{
|
|
"host": "localhost",
|
|
"port": 5432,
|
|
"user": "postgres",
|
|
"name": "calculate-negative-points",
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"database": dbConfig,
|
|
"environment": defaultEnvironment,
|
|
"listen": "127.0.0.1:3000",
|
|
}
|
|
}
|
|
|
|
func secretTypeTranslator[T SecretValue](field reflect.Value) interface{} {
|
|
if secret, ok := field.Interface().(Secret[T]); ok {
|
|
return secret.Value()
|
|
}
|
|
return nil
|
|
}
|