add auth support
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-04-17 13:19:08 +10:00
parent 9a561f3b07
commit ae3e2be89a
22 changed files with 2479 additions and 40 deletions
+173
View File
@@ -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
}