This commit is contained in:
335
internal/config/config.go
Normal file
335
internal/config/config.go
Normal file
@@ -0,0 +1,335 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user