@@ -1,6 +1,7 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -18,6 +19,20 @@ var (
|
||||
postgresKVPasswordPattern = regexp.MustCompile(`(?i)(\bpassword\s*=\s*)(?:'[^']*'|"[^"]*"|[^\s]+)`)
|
||||
)
|
||||
|
||||
const (
|
||||
authModeDisabled = "disabled"
|
||||
authModeOptional = "optional"
|
||||
authModeRequired = "required"
|
||||
|
||||
authRoleAdmin = "admin"
|
||||
authRoleViewer = "viewer"
|
||||
|
||||
defaultAuthTokenLifespanMinutes = 120
|
||||
defaultAuthJWTIssuer = "vctp"
|
||||
defaultAuthJWTAudience = "vctp-api"
|
||||
defaultAuthClockSkewSeconds = 60
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
SettingsPath string
|
||||
Logger *slog.Logger
|
||||
@@ -50,6 +65,21 @@ type SettingsYML struct {
|
||||
VcenterPassword string `yaml:"vcenter_password"`
|
||||
VcenterInsecure bool `yaml:"vcenter_insecure"`
|
||||
EnableLegacyAPI bool `yaml:"enable_legacy_api"`
|
||||
AuthEnabled bool `yaml:"auth_enabled"`
|
||||
AuthMode string `yaml:"auth_mode"`
|
||||
AuthJWTSigningKey string `yaml:"auth_jwt_signing_key"`
|
||||
AuthTokenLifespanMinutes int `yaml:"auth_token_lifespan_minutes"`
|
||||
AuthJWTIssuer string `yaml:"auth_jwt_issuer"`
|
||||
AuthJWTAudience string `yaml:"auth_jwt_audience"`
|
||||
AuthClockSkewSeconds int `yaml:"auth_clock_skew_seconds"`
|
||||
AuthGroupRoleMappings map[string]string `yaml:"auth_group_role_mappings"`
|
||||
LDAPGroups []string `yaml:"ldap_groups"`
|
||||
LDAPBindAddress string `yaml:"ldap_bind_address"`
|
||||
LDAPBaseDN string `yaml:"ldap_base_dn"`
|
||||
LDAPTrustCertFile string `yaml:"ldap_trust_cert_file"`
|
||||
LDAPDisableValidation bool `yaml:"ldap_disable_validation"`
|
||||
LDAPInsecure bool `yaml:"ldap_insecure"`
|
||||
EnablePprof bool `yaml:"enable_pprof"`
|
||||
VcenterEventPollingSeconds int `yaml:"vcenter_event_polling_seconds"`
|
||||
VcenterInventoryPollingSeconds int `yaml:"vcenter_inventory_polling_seconds"`
|
||||
VcenterInventorySnapshotSeconds int `yaml:"vcenter_inventory_snapshot_seconds"`
|
||||
@@ -112,6 +142,9 @@ func (s *Settings) ReadYMLSettings() error {
|
||||
if err := d.Decode(&settings); err != nil {
|
||||
return fmt.Errorf("unable to decode settings file : '%s'", err)
|
||||
}
|
||||
if err := applyDefaultsAndValidateSettings(&settings); err != nil {
|
||||
return fmt.Errorf("invalid settings file: %w", err)
|
||||
}
|
||||
|
||||
// Avoid logging sensitive fields (e.g., credentials).
|
||||
redacted := settings
|
||||
@@ -119,6 +152,9 @@ func (s *Settings) ReadYMLSettings() error {
|
||||
if redacted.Settings.EncryptionKey != "" {
|
||||
redacted.Settings.EncryptionKey = "REDACTED"
|
||||
}
|
||||
if redacted.Settings.AuthJWTSigningKey != "" {
|
||||
redacted.Settings.AuthJWTSigningKey = "REDACTED"
|
||||
}
|
||||
if redacted.Settings.DatabaseURL != "" {
|
||||
redacted.Settings.DatabaseURL = redactDatabaseURL(redacted.Settings.DatabaseURL)
|
||||
}
|
||||
@@ -189,3 +225,140 @@ func secureSettingsFileMode(mode os.FileMode) os.FileMode {
|
||||
secured |= 0o600
|
||||
return secured
|
||||
}
|
||||
|
||||
func applyDefaultsAndValidateSettings(cfg *SettingsYML) error {
|
||||
if cfg == nil {
|
||||
return errors.New("settings config is nil")
|
||||
}
|
||||
s := &cfg.Settings
|
||||
|
||||
s.AuthMode = strings.ToLower(strings.TrimSpace(s.AuthMode))
|
||||
if s.AuthMode == "" {
|
||||
s.AuthMode = authModeDisabled
|
||||
}
|
||||
if s.AuthTokenLifespanMinutes == 0 {
|
||||
s.AuthTokenLifespanMinutes = defaultAuthTokenLifespanMinutes
|
||||
}
|
||||
s.AuthJWTIssuer = strings.TrimSpace(s.AuthJWTIssuer)
|
||||
if s.AuthJWTIssuer == "" {
|
||||
s.AuthJWTIssuer = defaultAuthJWTIssuer
|
||||
}
|
||||
s.AuthJWTAudience = strings.TrimSpace(s.AuthJWTAudience)
|
||||
if s.AuthJWTAudience == "" {
|
||||
s.AuthJWTAudience = defaultAuthJWTAudience
|
||||
}
|
||||
if s.AuthClockSkewSeconds == 0 {
|
||||
s.AuthClockSkewSeconds = defaultAuthClockSkewSeconds
|
||||
}
|
||||
s.AuthJWTSigningKey = strings.TrimSpace(s.AuthJWTSigningKey)
|
||||
s.LDAPBindAddress = strings.TrimSpace(s.LDAPBindAddress)
|
||||
s.LDAPBaseDN = strings.TrimSpace(s.LDAPBaseDN)
|
||||
s.LDAPTrustCertFile = strings.TrimSpace(s.LDAPTrustCertFile)
|
||||
s.LDAPGroups = compactTrimmedStrings(s.LDAPGroups)
|
||||
|
||||
if !isValidAuthMode(s.AuthMode) {
|
||||
return fmt.Errorf("settings.auth_mode must be one of %q, %q, %q", authModeDisabled, authModeOptional, authModeRequired)
|
||||
}
|
||||
if s.AuthTokenLifespanMinutes <= 0 {
|
||||
return errors.New("settings.auth_token_lifespan_minutes must be greater than 0")
|
||||
}
|
||||
if s.AuthClockSkewSeconds < 0 {
|
||||
return errors.New("settings.auth_clock_skew_seconds must be >= 0")
|
||||
}
|
||||
|
||||
if len(s.AuthGroupRoleMappings) > 0 {
|
||||
normalized := make(map[string]string, len(s.AuthGroupRoleMappings))
|
||||
for groupDN, role := range s.AuthGroupRoleMappings {
|
||||
groupDN = strings.TrimSpace(groupDN)
|
||||
role = strings.ToLower(strings.TrimSpace(role))
|
||||
if groupDN == "" {
|
||||
return errors.New("settings.auth_group_role_mappings contains an empty group DN key")
|
||||
}
|
||||
if !isValidAuthRole(role) {
|
||||
return fmt.Errorf("settings.auth_group_role_mappings[%q] has unsupported role %q", groupDN, role)
|
||||
}
|
||||
normalized[groupDN] = role
|
||||
}
|
||||
s.AuthGroupRoleMappings = normalized
|
||||
}
|
||||
|
||||
if !s.AuthEnabled {
|
||||
return nil
|
||||
}
|
||||
if s.AuthMode == authModeDisabled {
|
||||
return errors.New("settings.auth_mode must be optional or required when settings.auth_enabled=true")
|
||||
}
|
||||
if s.AuthJWTSigningKey == "" {
|
||||
return errors.New("settings.auth_jwt_signing_key is required when settings.auth_enabled=true")
|
||||
}
|
||||
decodedKey, err := decodeBase64(s.AuthJWTSigningKey)
|
||||
if err != nil {
|
||||
return errors.New("settings.auth_jwt_signing_key must be valid base64")
|
||||
}
|
||||
if len(decodedKey) == 0 {
|
||||
return errors.New("settings.auth_jwt_signing_key cannot decode to an empty value")
|
||||
}
|
||||
if s.LDAPBindAddress == "" {
|
||||
return errors.New("settings.ldap_bind_address is required when settings.auth_enabled=true")
|
||||
}
|
||||
if s.LDAPBaseDN == "" {
|
||||
return errors.New("settings.ldap_base_dn is required when settings.auth_enabled=true")
|
||||
}
|
||||
if len(s.AuthGroupRoleMappings) == 0 {
|
||||
return errors.New("settings.auth_group_role_mappings must define at least one mapping when settings.auth_enabled=true")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isValidAuthMode(mode string) bool {
|
||||
switch mode {
|
||||
case authModeDisabled, authModeOptional, authModeRequired:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isValidAuthRole(role string) bool {
|
||||
switch role {
|
||||
case authRoleAdmin, authRoleViewer:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func decodeBase64(value string) ([]byte, error) {
|
||||
encodings := []*base64.Encoding{
|
||||
base64.StdEncoding,
|
||||
base64.RawStdEncoding,
|
||||
base64.URLEncoding,
|
||||
base64.RawURLEncoding,
|
||||
}
|
||||
for _, encoding := range encodings {
|
||||
decoded, err := encoding.DecodeString(value)
|
||||
if err == nil {
|
||||
return decoded, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("invalid base64 encoding")
|
||||
}
|
||||
|
||||
func compactTrimmedStrings(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
package settings
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRedactDatabaseURL_PostgresURI(t *testing.T) {
|
||||
input := "postgres://vctp_user:Secr3tP%40ss@db-host:5432/vctp?sslmode=disable"
|
||||
@@ -27,3 +34,29 @@ func TestRedactDatabaseURL_UnchangedWhenNoPassword(t *testing.T) {
|
||||
t.Fatalf("expected input to remain unchanged\nwant: %s\ngot: %s", input, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadYMLSettingsRedactsAuthJWTSigningKey(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
settingsPath := filepath.Join(tmpDir, "vctp.yml")
|
||||
content := `settings:
|
||||
auth_jwt_signing_key: "c2VjcmV0"
|
||||
`
|
||||
if err := os.WriteFile(settingsPath, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("failed to write settings file: %v", err)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&output, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
s := New(logger, settingsPath)
|
||||
if err := s.ReadYMLSettings(); err != nil {
|
||||
t.Fatalf("expected settings to load, got error: %v", err)
|
||||
}
|
||||
|
||||
logged := output.String()
|
||||
if strings.Contains(logged, "c2VjcmV0") {
|
||||
t.Fatalf("expected auth_jwt_signing_key to be redacted in logs, got log output: %s", logged)
|
||||
}
|
||||
if !strings.Contains(logged, "REDACTED") {
|
||||
t.Fatalf("expected redacted marker in logs, got log output: %s", logged)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,134 @@ func TestReadYMLSettingsRejectsUnknownField(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadYMLSettingsAppliesAuthDefaults(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
settingsPath := filepath.Join(tmpDir, "vctp.yml")
|
||||
content := `settings:
|
||||
log_level: "info"
|
||||
`
|
||||
if err := os.WriteFile(settingsPath, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("failed to write settings file: %v", err)
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
s := New(logger, settingsPath)
|
||||
if err := s.ReadYMLSettings(); err != nil {
|
||||
t.Fatalf("expected settings to load, got error: %v", err)
|
||||
}
|
||||
|
||||
got := s.Values.Settings
|
||||
if got.AuthMode != authModeDisabled {
|
||||
t.Fatalf("expected default auth_mode=%q, got %q", authModeDisabled, got.AuthMode)
|
||||
}
|
||||
if got.AuthTokenLifespanMinutes != defaultAuthTokenLifespanMinutes {
|
||||
t.Fatalf("expected default auth_token_lifespan_minutes=%d, got %d", defaultAuthTokenLifespanMinutes, got.AuthTokenLifespanMinutes)
|
||||
}
|
||||
if got.AuthJWTIssuer != defaultAuthJWTIssuer {
|
||||
t.Fatalf("expected default auth_jwt_issuer=%q, got %q", defaultAuthJWTIssuer, got.AuthJWTIssuer)
|
||||
}
|
||||
if got.AuthJWTAudience != defaultAuthJWTAudience {
|
||||
t.Fatalf("expected default auth_jwt_audience=%q, got %q", defaultAuthJWTAudience, got.AuthJWTAudience)
|
||||
}
|
||||
if got.AuthClockSkewSeconds != defaultAuthClockSkewSeconds {
|
||||
t.Fatalf("expected default auth_clock_skew_seconds=%d, got %d", defaultAuthClockSkewSeconds, got.AuthClockSkewSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadYMLSettingsRejectsInvalidAuthMode(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
settingsPath := filepath.Join(tmpDir, "vctp.yml")
|
||||
content := `settings:
|
||||
auth_mode: "sometimes"
|
||||
`
|
||||
if err := os.WriteFile(settingsPath, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("failed to write settings file: %v", err)
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
s := New(logger, settingsPath)
|
||||
err := s.ReadYMLSettings()
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid auth_mode to fail")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "auth_mode") {
|
||||
t.Fatalf("expected error to mention auth_mode, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadYMLSettingsRejectsAuthEnabledWithoutSigningKey(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
settingsPath := filepath.Join(tmpDir, "vctp.yml")
|
||||
content := `settings:
|
||||
auth_enabled: true
|
||||
auth_mode: "required"
|
||||
ldap_bind_address: "ldaps://ldap.example.com:636"
|
||||
ldap_base_dn: "dc=example,dc=com"
|
||||
auth_group_role_mappings:
|
||||
"cn=vctp-admin,ou=groups,dc=example,dc=com": "admin"
|
||||
`
|
||||
if err := os.WriteFile(settingsPath, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("failed to write settings file: %v", err)
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
s := New(logger, settingsPath)
|
||||
err := s.ReadYMLSettings()
|
||||
if err == nil {
|
||||
t.Fatal("expected auth_enabled=true without signing key to fail")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "auth_jwt_signing_key") {
|
||||
t.Fatalf("expected error to mention auth_jwt_signing_key, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadYMLSettingsAcceptsValidAuthConfigAndNormalizesMappings(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
settingsPath := filepath.Join(tmpDir, "vctp.yml")
|
||||
content := `settings:
|
||||
auth_enabled: true
|
||||
auth_mode: "REQUIRED"
|
||||
auth_jwt_signing_key: "c2VjcmV0"
|
||||
auth_token_lifespan_minutes: 90
|
||||
auth_jwt_issuer: " custom-issuer "
|
||||
auth_jwt_audience: " custom-audience "
|
||||
auth_clock_skew_seconds: 15
|
||||
ldap_bind_address: "ldaps://ldap.example.com:636"
|
||||
ldap_base_dn: "dc=example,dc=com"
|
||||
ldap_groups:
|
||||
- " cn=vctp-viewers,ou=groups,dc=example,dc=com "
|
||||
auth_group_role_mappings:
|
||||
" cn=vctp-admins,ou=groups,dc=example,dc=com ": " ADMIN "
|
||||
"cn=vctp-viewers,ou=groups,dc=example,dc=com": "viewer"
|
||||
`
|
||||
if err := os.WriteFile(settingsPath, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("failed to write settings file: %v", err)
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
s := New(logger, settingsPath)
|
||||
if err := s.ReadYMLSettings(); err != nil {
|
||||
t.Fatalf("expected valid auth config, got error: %v", err)
|
||||
}
|
||||
|
||||
got := s.Values.Settings
|
||||
if got.AuthMode != authModeRequired {
|
||||
t.Fatalf("expected normalized auth_mode=%q, got %q", authModeRequired, got.AuthMode)
|
||||
}
|
||||
if got.AuthJWTIssuer != "custom-issuer" {
|
||||
t.Fatalf("expected trimmed auth_jwt_issuer, got %q", got.AuthJWTIssuer)
|
||||
}
|
||||
if got.AuthJWTAudience != "custom-audience" {
|
||||
t.Fatalf("expected trimmed auth_jwt_audience, got %q", got.AuthJWTAudience)
|
||||
}
|
||||
if len(got.LDAPGroups) != 1 || got.LDAPGroups[0] != "cn=vctp-viewers,ou=groups,dc=example,dc=com" {
|
||||
t.Fatalf("expected ldap_groups to be compacted+trimmed, got %#v", got.LDAPGroups)
|
||||
}
|
||||
if got.AuthGroupRoleMappings["cn=vctp-admins,ou=groups,dc=example,dc=com"] != authRoleAdmin {
|
||||
t.Fatalf("expected admin mapping to normalize role to %q, got %#v", authRoleAdmin, got.AuthGroupRoleMappings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureSettingsFileMode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
Reference in New Issue
Block a user